ソースを参照

feat:燃气管网-基础数据管理

null 1 ヶ月 前
コミット
1a89be82e0

+ 124 - 0
src/api/pipeNetwork/basic.js

@@ -0,0 +1,124 @@
+import request from '@/utils/request'
+
+// ============ 燃气管网基础数据 ============
+export function getPipeNetworkPage(pageNum, pageSize, params) {
+  return request({ url: '/api/pipe-network/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getPipeNetworkById(id) {
+  return request({ url: '/api/pipe-network/getById/' + id, method: 'get' })
+}
+export function getPipeNetworkOptions() {
+  return request({ url: '/api/pipe-network/options', method: 'get' })
+}
+export function addPipeNetwork(data) {
+  return request({ url: '/api/pipe-network/save', method: 'post', data })
+}
+export function updatePipeNetwork(data) {
+  return request({ url: '/api/pipe-network/updateById', method: 'put', data })
+}
+export function deletePipeNetwork(id) {
+  return request({ url: '/api/pipe-network/deleteById', method: 'delete', params: { id } })
+}
+
+// ============ 窨井管理 ============
+export function getManholePage(pageNum, pageSize, params) {
+  return request({ url: '/api/manhole/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getManholeById(id) {
+  return request({ url: '/api/manhole/getById/' + id, method: 'get' })
+}
+export function getManholeOptions() {
+  return request({ url: '/api/manhole/options', method: 'get' })
+}
+export function getManholeList() {
+  return request({ url: '/api/manhole/list', method: 'get' })
+}
+export function addManhole(data) {
+  return request({ url: '/api/manhole/save', method: 'post', data })
+}
+export function updateManhole(data) {
+  return request({ url: '/api/manhole/updateById', method: 'put', data })
+}
+export function deleteManhole(id) {
+  return request({ url: '/api/manhole/deleteById', method: 'delete', params: { id } })
+}
+
+// ============ 维修记录 ============
+export function getRepairRecordPage(pageNum, pageSize, params) {
+  return request({ url: '/api/repair-record/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getRepairRecordById(id) {
+  return request({ url: '/api/repair-record/getById/' + id, method: 'get' })
+}
+export function getRepairRecordByNetwork(networkId) {
+  return request({ url: '/api/repair-record/list-by-network/' + networkId, method: 'get' })
+}
+export function addRepairRecord(data) {
+  return request({ url: '/api/repair-record/save', method: 'post', data })
+}
+export function updateRepairRecord(data) {
+  return request({ url: '/api/repair-record/updateById', method: 'put', data })
+}
+export function deleteRepairRecord(id) {
+  return request({ url: '/api/repair-record/deleteById', method: 'delete', params: { id } })
+}
+
+// ============ 监测设备管理 ============
+export function getEquipmentPage(pageNum, pageSize, params) {
+  return request({ url: '/EquipmentBase/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getEquipmentById(id) {
+  return request({ url: '/EquipmentBase/getById/' + id, method: 'get' })
+}
+export function addEquipment(data) {
+  return request({ url: '/EquipmentBase/save', method: 'post', data })
+}
+export function updateEquipment(data) {
+  return request({ url: '/EquipmentBase/updateById', method: 'put', data })
+}
+export function deleteEquipment(id) {
+  return request({ url: '/EquipmentBase/deleteById', method: 'delete', params: { id } })
+}
+export function getEquipmentStatus(equipmentId) {
+  return request({ url: '/api/equipment/status/' + equipmentId, method: 'get' })
+}
+export function getEquipmentAbnormalList() {
+  return request({ url: '/api/equipment/abnormal', method: 'get' })
+}
+export function getPointRelByEquipment(equipmentId) {
+  return request({ url: '/api/equipment/point-rel/' + equipmentId, method: 'get' })
+}
+export function savePointRel(data) {
+  return request({ url: '/api/equipment/point-rel/save', method: 'post', data })
+}
+export function deletePointRel(relId) {
+  return request({ url: '/api/equipment/point-rel/delete', method: 'delete', params: { relId } })
+}
+
+// ============ 测点管理 ============
+export function getPointPage(pageNum, pageSize, params) {
+  return request({ url: '/api/monitor-point/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getPointById(id) {
+  return request({ url: '/api/monitor-point/getById/' + id, method: 'get' })
+}
+export function addPoint(data) {
+  return request({ url: '/api/monitor-point/save', method: 'post', data })
+}
+export function updatePoint(data) {
+  return request({ url: '/api/monitor-point/updateById', method: 'put', data })
+}
+export function deletePoint(id) {
+  return request({ url: '/api/monitor-point/deleteById', method: 'delete', params: { id } })
+}
+
+// ============ GIS一张图 ============
+export function getGisPipeLines(params) {
+  return request({ url: '/api/gis/pipe-lines', method: 'get', params })
+}
+export function getGisManholes() {
+  return request({ url: '/api/gis/manholes', method: 'get' })
+}
+export function getGisEquipments(params) {
+  return request({ url: '/api/gis/equipments', method: 'get', params })
+}

+ 81 - 0
src/api/pipeNetwork/hazard.js

@@ -0,0 +1,81 @@
+import request from '@/utils/request'
+
+// ============ 隐患台账管理 ============
+export function getHazardPage(pageNum, pageSize, params) {
+  return request({ url: '/api/hazard/page', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getHazardById(id) {
+  return request({ url: '/api/hazard/getById/' + id, method: 'get' })
+}
+export function addHazard(data) {
+  return request({ url: '/api/hazard/save', method: 'post', data })
+}
+export function updateHazard(data) {
+  return request({ url: '/api/hazard/updateById', method: 'put', data })
+}
+export function deleteHazard(id) {
+  return request({ url: '/api/hazard/deleteById', method: 'delete', params: { id } })
+}
+export function submitHazard(hazardId) {
+  return request({ url: '/api/hazard/submit/' + hazardId, method: 'put' })
+}
+export function getHazardMapPoints() {
+  return request({ url: '/api/hazard/map-points', method: 'get' })
+}
+export function getHazardStatisticsByType() {
+  return request({ url: '/api/hazard/statistics/type', method: 'get' })
+}
+export function getHazardStatisticsByStatus() {
+  return request({ url: '/api/hazard/statistics/status', method: 'get' })
+}
+
+// ============ 监测预警 ============
+export function getAlarmPage(pageNum, pageSize, params) {
+  return request({ url: '/api/alarm/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getAlarmById(id) {
+  return request({ url: '/api/alarm/getById/' + id, method: 'get' })
+}
+export function handleAlarm(alarmId, handleUser, handleRemark) {
+  return request({ url: '/api/alarm/handle/' + alarmId, method: 'put', params: { handleUser, handleRemark } })
+}
+export function getActiveAlarmPoints() {
+  return request({ url: '/api/alarm/active-points', method: 'get' })
+}
+
+// ============ 实时监测 ============
+export function getPressureHistory(pointId, hours) {
+  return request({ url: '/api/monitor/pressure-history', method: 'get', params: { pointId, hours } })
+}
+export function getFlowHistory(pointId, hours) {
+  return request({ url: '/api/monitor/flow-history', method: 'get', params: { pointId, hours } })
+}
+export function getGasAlarmLatest() {
+  return request({ url: '/api/monitor/gas-alarm-latest', method: 'get' })
+}
+export function getVideoList() {
+  return request({ url: '/api/monitor/video-list', method: 'get' })
+}
+export function getPointsWithLatestData() {
+  return request({ url: '/api/monitor/points-with-latest-data', method: 'get' })
+}
+
+// ============ 风险评估 ============
+export function getRiskPage(pageNum, pageSize, params) {
+  return request({ url: '/api/risk/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+}
+export function getRiskById(riskId) {
+  return request({ url: '/api/risk/' + riskId, method: 'get' })
+}
+export function addRisk(data) {
+  return request({ url: '/api/risk/save', method: 'post', data })
+}
+export function updateRisk(data) {
+  return request({ url: '/api/risk/updateById', method: 'put', data })
+}
+export function deleteRisk(id) {
+  return request({ url: '/api/risk/deleteById', method: 'delete', params: { id } })
+}
+export function getFourColorMap() {
+  return request({ url: '/api/risk/four-color-map', method: 'get' })
+}

+ 136 - 0
src/views/subSystem/alarm/AlarmList.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="alarm-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="报警类型">
+          <el-select v-model="searchForm.warningType" placeholder="请选择" clearable>
+            <el-option v-for="d in alarmTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="处理状态">
+          <el-select v-model="searchForm.alarmStatus" placeholder="请选择" clearable>
+            <el-option v-for="d in alarmStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="deviceCode" label="设备编码" min-width="120" />
+        <el-table-column prop="warningType" label="报警类型" width="120">
+          <template #default="{ row }">{{ getDictLabel(alarmTypeDict, row.warningType) }}</template>
+        </el-table-column>
+        <el-table-column prop="warningCode" label="预警编码" width="100" />
+        <el-table-column prop="minValue" label="最小值" width="90" />
+        <el-table-column prop="maxValue" label="最大值" width="90" />
+        <el-table-column prop="actualValue" label="实际值" width="90">
+          <template #default="{ row }">
+            <span :style="{ color: row.actualValue > row.maxValue || row.actualValue < row.minValue ? '#f5222d' : '#52c41a', fontWeight: 'bold' }">{{ row.actualValue }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="alarmStatus" label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="row.alarmStatus === '0' ? 'danger' : 'success'">{{ getDictLabel(alarmStatusDict, row.alarmStatus) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="alarmTime" label="报警时间" width="160" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template #default="{ row }">
+            <el-button v-if="row.alarmStatus === 0" type="warning" size="small" @click="handleOpenHandle(row)">处理</el-button>
+            <el-tag v-else type="success" size="small">已处理</el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog title="处理报警" v-model="handleDialogVisible" width="450px">
+      <el-form :model="handleForm" label-width="80px">
+        <el-form-item label="处理人">
+          <el-input v-model="handleForm.handleUser" placeholder="请输入处理人" />
+        </el-form-item>
+        <el-form-item label="处理备注">
+          <el-input v-model="handleForm.handleRemark" type="textarea" :rows="3" placeholder="请输入处理备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="handleDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确认处理</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getAlarmPage, handleAlarm } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ warningType: '', alarmStatus: '' })
+
+const alarmTypeDict = ref([])
+const alarmStatusDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === String(value))
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const [typeRes, statusRes] = await Promise.all([
+    getDicts('alarm_type'),
+    getDicts('alarm_status')
+  ])
+  alarmTypeDict.value = typeRes.data
+  alarmStatusDict.value = statusRes.data
+}
+const handleDialogVisible = ref(false)
+const currentAlarmId = ref('')
+const handleForm = reactive({ handleUser: '', handleRemark: '' })
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getAlarmPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { warningType: '', alarmStatus: '' }); handleSearch() }
+function handleOpenHandle(row) {
+  currentAlarmId.value = row.id
+  Object.assign(handleForm, { handleUser: '', handleRemark: '' })
+  handleDialogVisible.value = true
+}
+async function handleConfirm() {
+  try {
+    await handleAlarm(currentAlarmId.value, handleForm.handleUser, handleForm.handleRemark)
+    ElMessage.success('处理成功')
+    handleDialogVisible.value = false
+    loadData()
+  } catch (e) { console.error(e) }
+}
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.alarm-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 70 - 0
src/views/subSystem/alarm/AlarmMap.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="alarm-map-container">
+    <el-card class="legend-card">
+      <template #header><span>图例</span></template>
+      <div class="legend-items">
+        <div class="legend-item"><span class="dot" style="background:#f5222d"></span> 压力异常</div>
+        <div class="legend-item"><span class="dot" style="background:#fa8c16"></span> 流量异常</div>
+        <div class="legend-item"><span class="dot" style="background:#722ed1"></span> 气体浓度异常</div>
+      </div>
+    </el-card>
+    <div id="alarmMap" class="map"></div>
+    <el-drawer v-model="detailVisible" title="报警详情" size="400px">
+      <template v-if="currentAlarm">
+        <el-descriptions :column="1" border size="small">
+          <el-descriptions-item label="设备编码">{{ currentAlarm.deviceCode }}</el-descriptions-item>
+          <el-descriptions-item label="报警类型">{{ currentAlarm.warningType }}</el-descriptions-item>
+          <el-descriptions-item label="实际值">{{ currentAlarm.actualValue }}</el-descriptions-item>
+          <el-descriptions-item label="最小值">{{ currentAlarm.minValue }}</el-descriptions-item>
+          <el-descriptions-item label="最大值">{{ currentAlarm.maxValue }}</el-descriptions-item>
+          <el-descriptions-item label="报警时间">{{ currentAlarm.alarmTime }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, nextTick } from 'vue'
+import { getActiveAlarmPoints } from '@/api/pipeNetwork/hazard'
+
+const detailVisible = ref(false)
+const currentAlarm = ref(null)
+let mapInstance = null
+const typeColors = { '压力异常': '#f5222d', '流量异常': '#fa8c16', '气体浓度异常': '#722ed1' }
+
+async function initMap() {
+  await nextTick()
+  const el = document.getElementById('alarmMap')
+  if (!el) return
+  try {
+    const L = await import('leaflet')
+    mapInstance = L.map('alarmMap').setView([34.77, 113.65], 12)
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; OpenStreetMap contributors'
+    }).addTo(mapInstance)
+
+    const res = await getActiveAlarmPoints()
+    const points = res.data || []
+    for (const p of points) {
+      const color = typeColors[p.warningType] || '#999'
+      const marker = L.circleMarker([34.77 + (Math.random() - 0.5) * 0.02, 113.65 + (Math.random() - 0.5) * 0.02], {
+        radius: 10, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.8
+      }).addTo(mapInstance)
+      marker.bindPopup(`<b>${p.deviceCode}</b><br/>${p.warningType}<br/>值: ${p.actualValue}`)
+      marker.on('click', () => { currentAlarm.value = p; detailVisible.value = true })
+    }
+  } catch (e) { el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">请安装Leaflet: npm install leaflet</div>' }
+}
+
+onMounted(() => { initMap() })
+</script>
+
+<style scoped>
+.alarm-map-container { position: relative; height: calc(100vh - 100px); padding: 16px; }
+.legend-card { position: absolute; top: 32px; right: 32px; z-index: 1000; width: 160px; }
+.legend-items { display: flex; flex-direction: column; gap: 6px; }
+.legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
+.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
+.map { width: 100%; height: 100%; border-radius: 4px; }
+</style>

+ 172 - 0
src/views/subSystem/basic/Equipment.vue

@@ -0,0 +1,172 @@
+<template>
+  <div class="equipment-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="设备编码">
+          <el-input v-model="searchForm.equipmentCode" placeholder="请输入设备编码" clearable />
+        </el-form-item>
+        <el-form-item label="设备名称">
+          <el-input v-model="searchForm.equipmentName" placeholder="请输入设备名称" clearable />
+        </el-form-item>
+        <el-form-item label="设备型号">
+          <el-input v-model="searchForm.equipmentModel" placeholder="请输入设备型号" clearable />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="equipmentCode" label="设备编码" min-width="120" />
+        <el-table-column prop="equipmentName" label="设备名称" min-width="150" />
+        <el-table-column prop="equipmentModel" label="设备型号" width="120" />
+        <el-table-column prop="equipmentSpec" label="规格参数" width="120" />
+        <el-table-column prop="manufacturer" label="制造商" width="110" />
+        <el-table-column prop="equipmentLocation" label="安装位置" min-width="150" />
+        <el-table-column prop="maintainer" label="维护人员" width="100" />
+        <el-table-column label="运行状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.statusInfo ? 'success' : 'warning'" size="small">{{ row.statusInfo ? '在线' : '未知' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="info" size="small" @click="handleDetail(row)">详情</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog title="设备详情" v-model="detailVisible" width="700px">
+      <el-descriptions v-if="detailData" :column="2" border size="small">
+        <el-descriptions-item label="设备编码">{{ detailData.equipmentCode }}</el-descriptions-item>
+        <el-descriptions-item label="设备名称">{{ detailData.equipmentName }}</el-descriptions-item>
+        <el-descriptions-item label="设备型号">{{ detailData.equipmentModel }}</el-descriptions-item>
+        <el-descriptions-item label="制造商">{{ detailData.manufacturer }}</el-descriptions-item>
+        <el-descriptions-item label="安装位置">{{ detailData.equipmentLocation }}</el-descriptions-item>
+        <el-descriptions-item label="维护人员">{{ detailData.maintainer }}</el-descriptions-item>
+        <el-descriptions-item label="联系方式">{{ detailData.maintainerPhone }}</el-descriptions-item>
+        <el-descriptions-item label="安装时间">{{ detailData.installTime }}</el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="650px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-form-item label="设备编码" prop="equipmentCode">
+          <el-input v-model="formData.equipmentCode" placeholder="请输入设备编码" />
+        </el-form-item>
+        <el-form-item label="设备名称" prop="equipmentName">
+          <el-input v-model="formData.equipmentName" placeholder="请输入设备名称" />
+        </el-form-item>
+        <el-form-item label="设备型号">
+          <el-input v-model="formData.equipmentModel" />
+        </el-form-item>
+        <el-form-item label="规格参数">
+          <el-input v-model="formData.equipmentSpec" />
+        </el-form-item>
+        <el-form-item label="制造商">
+          <el-input v-model="formData.manufacturer" />
+        </el-form-item>
+        <el-form-item label="安装位置">
+          <el-input v-model="formData.equipmentLocation" />
+        </el-form-item>
+        <el-form-item label="经度">
+          <el-input-number v-model="formData.longitude" :precision="10" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="纬度">
+          <el-input-number v-model="formData.latitude" :precision="10" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="维护人员">
+          <el-input v-model="formData.maintainer" />
+        </el-form-item>
+        <el-form-item label="联系方式">
+          <el-input v-model="formData.maintainerPhone" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getEquipmentPage, getEquipmentById, addEquipment, updateEquipment, deleteEquipment } from '@/api/pipeNetwork/basic'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ equipmentCode: '', equipmentName: '', equipmentModel: '' })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const detailVisible = ref(false)
+const detailData = ref(null)
+const formData = reactive({
+  equipmentId: '', equipmentCode: '', equipmentName: '', equipmentModel: '',
+  equipmentSpec: '', manufacturer: '', equipmentLocation: '',
+  longitude: null, latitude: null, maintainer: '', maintainerPhone: '', remark: ''
+})
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getEquipmentPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { equipmentCode: '', equipmentName: '', equipmentModel: '' }); handleSearch() }
+function handleAdd() {
+  dialogTitle.value = '新增'
+  Object.assign(formData, { equipmentId: '', equipmentCode: '', equipmentName: '', equipmentModel: '', equipmentSpec: '', manufacturer: '', equipmentLocation: '', longitude: null, latitude: null, maintainer: '', maintainerPhone: '', remark: '' })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSave() {
+  try {
+    if (formData.equipmentId) { await updateEquipment(formData); ElMessage.success('修改成功') }
+    else { await addEquipment(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该设备信息?', '提示', { type: 'warning' }).then(async () => {
+    await deleteEquipment(row.equipmentId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+async function handleDetail(row) {
+  try {
+    const res = await getEquipmentById(row.equipmentId)
+    detailData.value = res.data
+    detailVisible.value = true
+  } catch (e) { console.error(e) }
+}
+
+onMounted(() => { loadData() })
+</script>
+
+<style scoped>
+.equipment-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 736 - 0
src/views/subSystem/basic/EquipmentAbnormal.vue

@@ -0,0 +1,736 @@
+<template>
+  <div class="equipment-abnormal">
+    <!-- 左侧面板 -->
+    <div class="left-panel">
+      <!-- 搜索 -->
+      <div class="panel-card search-card">
+        <div class="panel-title"><el-icon><Search /></el-icon> 快速查询</div>
+        <el-input v-model="searchKey" placeholder="设备名称/编号/地址" clearable prefix-icon="Search" size="default" />
+        <el-select v-model="levelFilter" placeholder="异常等级" clearable style="width:100%;margin-top:10px">
+          <el-option label="严重" value="严重" />
+          <el-option label="一般" value="一般" />
+          <el-option label="轻微" value="轻微" />
+        </el-select>
+        <el-select v-model="typeFilter" placeholder="设备类型" clearable style="width:100%;margin-top:10px">
+          <el-option label="阀门" value="阀门" />
+          <el-option label="调压器" value="调压器" />
+          <el-option label="流量计" value="流量计" />
+          <el-option label="窨井盖" value="窨井盖" />
+          <el-option label="终端节点" value="终端节点" />
+          <el-option label="检查井" value="检查井" />
+        </el-select>
+        <el-select v-model="statusFilter" placeholder="处理状态" clearable style="width:100%;margin-top:10px">
+          <el-option label="待处理" value="待处理" />
+          <el-option label="处理中" value="处理中" />
+          <el-option label="已修复" value="已修复" />
+        </el-select>
+        <div style="display:flex;gap:8px;margin-top:10px">
+          <el-button type="primary" size="small" @click="handleQuery" style="flex:1">查询</el-button>
+          <el-button size="small" @click="handleReset" style="flex:1">重置</el-button>
+        </div>
+      </div>
+
+      <!-- 异常等级分布 -->
+      <div class="panel-card">
+        <div class="panel-title"><el-icon><PieChart /></el-icon> 异常等级分布</div>
+        <div class="level-list">
+          <div class="level-item critical">
+            <span class="level-dot"></span>
+            <span class="level-label">严重</span>
+            <span class="level-count">{{ stats.critical }}</span>
+          </div>
+          <div class="level-item warning">
+            <span class="level-dot"></span>
+            <span class="level-label">一般</span>
+            <span class="level-count">{{ stats.normal }}</span>
+          </div>
+          <div class="level-item minor">
+            <span class="level-dot"></span>
+            <span class="level-label">轻微</span>
+            <span class="level-count">{{ stats.minor }}</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 设备类型统计 -->
+      <div class="panel-card">
+        <div class="panel-title"><el-icon><Histogram /></el-icon> 设备类型统计</div>
+        <div class="type-list">
+          <div class="type-item" v-for="item in deviceTypeStats" :key="item.name">
+            <span class="type-name">{{ item.name }}</span>
+            <span class="type-bar-bg"><span class="type-bar" :style="{width: (item.count/stats.total*100)+'%', background: item.color}"></span></span>
+            <span class="type-count">{{ item.count }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 右侧内容区 -->
+    <div class="right-panel">
+      <!-- 统计卡片 -->
+      <div class="stats-row">
+        <div class="stat-card stat-total">
+          <div class="stat-icon"><el-icon size="22"><WarningFilled /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">异常设备总数</div>
+            <div class="stat-value">{{ stats.total }} <small>台</small></div>
+          </div>
+        </div>
+        <div class="stat-card stat-today">
+          <div class="stat-icon"><el-icon size="22"><Clock /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">今日新增</div>
+            <div class="stat-value">{{ stats.today }} <small>台</small></div>
+          </div>
+        </div>
+        <div class="stat-card stat-rate">
+          <div class="stat-icon"><el-icon size="22"><TrendCharts /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">异常占比</div>
+            <div class="stat-value">{{ stats.rate }} <small>%</small></div>
+          </div>
+        </div>
+        <div class="stat-card stat-processed">
+          <div class="stat-icon"><el-icon size="22"><CircleCheckFilled /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">已修复</div>
+            <div class="stat-value">{{ stats.processed }} <small>台</small></div>
+          </div>
+        </div>
+        <div class="stat-card stat-pending">
+          <div class="stat-icon"><el-icon size="22"><Warning /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">待处理</div>
+            <div class="stat-value">{{ stats.pending }} <small>台</small></div>
+          </div>
+        </div>
+        <div class="stat-card stat-process-rate">
+          <div class="stat-icon"><el-icon size="22"><DataAnalysis /></el-icon></div>
+          <div class="stat-info">
+            <div class="stat-label">修复率</div>
+            <div class="stat-value">{{ stats.processRate }} <small>%</small></div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 图表区域 - 上方: 趋势图(全宽) -->
+      <div class="chart-card chart-trend">
+        <div class="chart-header">
+          <span class="chart-title">设备异常趋势图</span>
+          <span class="chart-subtitle">近15天运行数据 (2026-05-29 ~ 2026-06-12)</span>
+          <div class="chart-legend-custom">
+            <span class="legend-tag tag-total">异常总数</span>
+            <span class="legend-tag tag-new">新增异常</span>
+            <span class="legend-tag tag-fixed">已修复</span>
+          </div>
+        </div>
+        <div id="trendChart" class="chart-body chart-body-large" style="height:340px"></div>
+      </div>
+
+      <!-- 图表区域 - 下方: 两个图表并排 -->
+      <div class="charts-row">
+        <div class="chart-card chart-half">
+          <div class="chart-header">
+            <span class="chart-title">异常类型分布</span>
+          </div>
+          <div id="typeChart" class="chart-body" style="height:290px"></div>
+        </div>
+        <div class="chart-card chart-half">
+          <div class="chart-header">
+            <span class="chart-title">设备异常等级与处理状态</span>
+          </div>
+          <div id="levelChart" class="chart-body" style="height:290px"></div>
+        </div>
+      </div>
+
+      <!-- 异常设备列表 -->
+      <div class="table-card">
+        <div class="table-header">
+          <span class="table-title">异常设备列表</span>
+          <span class="table-count">共 {{ filteredTableData.length }} 条记录</span>
+        </div>
+        <el-table :data="paginatedData" border stripe v-loading="loading" max-height="460" size="small">
+          <el-table-column prop="id" label="序号" width="56" align="center" />
+          <el-table-column prop="deviceCode" label="设备编号" min-width="140" show-overflow-tooltip />
+          <el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip />
+          <el-table-column prop="deviceType" label="设备类型" width="100" />
+          <el-table-column prop="level" label="异常等级" width="100" align="center">
+            <template #default="{ row }">
+              <el-tag :type="levelTagType(row.level)" size="small">{{ row.level }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="alarmDesc" label="异常描述" min-width="220" show-overflow-tooltip />
+          <el-table-column prop="address" label="安装地址" min-width="200" show-overflow-tooltip />
+          <el-table-column prop="alarmTime" label="异常时间" width="168" />
+          <el-table-column prop="processStatus" label="处理状态" width="100" align="center">
+            <template #default="{ row }">
+              <el-tag :type="row.processStatus === '已修复' ? 'success' : row.processStatus === '处理中' ? 'warning' : 'danger'" size="small">
+                {{ row.processStatus }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="150" fixed="right" align="center">
+            <template #default="{ row }">
+              <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
+              <el-button v-if="row.processStatus === '待处理'" type="warning" link size="small" @click="handleProcess(row)">处理</el-button>
+              <el-button v-if="row.processStatus === '处理中'" type="success" link size="small" @click="handleComplete(row)">完成</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="table-footer">
+          <el-pagination
+            v-model:current-page="currentPage"
+            v-model:page-size="pageSize"
+            :total="filteredTableData.length"
+            layout="total, prev, pager, next"
+            :page-sizes="[10, 20]"
+            small
+            background
+          />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import * as echarts from 'echarts'
+import { Clock, CircleCheckFilled, DataAnalysis, Histogram, PieChart, Search, TrendCharts, Warning, WarningFilled } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+const loading = ref(false)
+const searchKey = ref('')
+const levelFilter = ref('')
+const typeFilter = ref('')
+const statusFilter = ref('')
+const currentPage = ref(1)
+const pageSize = ref(10)
+
+let trendChart = null
+let typeChart = null
+let levelChart = null
+
+// ==================== 统计数据 ====================
+const stats = reactive({
+  total: 28,
+  today: 3,
+  rate: 7.2,
+  processed: 18,
+  pending: 10,
+  processRate: 64.3,
+  critical: 10,
+  normal: 10,
+  minor: 8
+})
+
+const deviceTypeStats = ref([
+  { name: '阀门', count: 4, color: '#e63946' },
+  { name: '调压器', count: 3, color: '#f77f00' },
+  { name: '流量计', count: 4, color: '#2d6a4f' },
+  { name: '窨井盖', count: 5, color: '#7f4f24' },
+  { name: '终端节点', count: 2, color: '#6c757d' },
+  { name: '检查井', count: 1, color: '#b08968' }
+])
+
+// ==================== 趋势数据 ====================
+const trendDates = [
+  '05-29', '05-30', '05-31', '06-01', '06-02', '06-03', '06-04',
+  '06-05', '06-06', '06-07', '06-08', '06-09', '06-10', '06-11', '06-12'
+]
+const trendTotalData   = [24, 26, 25, 28, 27, 25, 24, 26, 28, 29, 27, 28, 30, 28, 28]
+const trendNewData     = [ 3,  5,  2,  6,  4,  2,  1,  3,  5,  4,  2,  3,  5,  3,  3]
+const trendProcessedData = [20, 21, 23, 22, 23, 23, 23, 23, 23, 25, 25, 25, 25, 25, 18]
+
+// ==================== 表格数据 ====================
+const tableData = ref([
+  { id: '1', deviceCode:'GD-FM-001', deviceName:'辰州北路阀门', deviceType:'阀门', level:'严重', alarmDesc:'阀门开度异常,远程控制无响应,现场检查阀体锈蚀严重,存在燃气泄漏风险', address:'辰州北路与天宁路交叉口北侧50米', alarmTime:'2026-06-12 04:23:15', processStatus:'待处理' },
+  { id: '2', deviceCode:'GD-TY-001', deviceName:'辰州路调压站', deviceType:'调压器', level:'严重', alarmDesc:'出口压力波动超限,调压器膜片老化,压力调节范围偏离正常值±15%', address:'辰州中路县政府东侧200米', alarmTime:'2026-06-12 02:15:42', processStatus:'处理中' },
+  { id: '3', deviceCode:'GD-LL-002', deviceName:'迎宾西路计量站', deviceType:'流量计', level:'一般', alarmDesc:'流量计量偏差超出允许范围,瞬时流量数据跳变,疑似传感器零点漂移', address:'迎宾西路与古城路交叉口', alarmTime:'2026-06-11 18:47:30', processStatus:'处理中' },
+  { id: '4', deviceCode:'GD-FM-003', deviceName:'古城北路阀门', deviceType:'阀门', level:'一般', alarmDesc:'阀门关闭不到位,密封面疑似有异物卡滞,泄漏检测仪报警', address:'古城北路中段天宁路南侧100米', alarmTime:'2026-06-11 11:32:08', processStatus:'待处理' },
+  { id: '5', deviceCode:'YJ-JG-004', deviceName:'迎宾路窨井盖', deviceType:'窨井盖', level:'严重', alarmDesc:'井盖倾斜位移超过安全阈值,井盖锁失效,存在行人及车辆安全隐患', address:'迎宾中路公交站台北侧', alarmTime:'2026-06-11 06:55:20', processStatus:'待处理' },
+  { id: '6', deviceCode:'GD-LL-001', deviceName:'辰州北路计量站', deviceType:'流量计', level:'轻微', alarmDesc:'通讯延迟偏高,数据上报间隔从15秒延长至45秒,4G信号强度偏弱', address:'辰州北路与滨江路交叉口附近', alarmTime:'2026-06-10 22:10:05', processStatus:'已修复' },
+  { id: '7', deviceCode:'GD-TY-003', deviceName:'建设路调压柜', deviceType:'调压器', level:'一般', alarmDesc:'调压柜门锁损坏,柜内温湿度传感器读数偏高,可能存在进水风险', address:'建设西路中段加油站对面', alarmTime:'2026-06-10 14:28:45', processStatus:'已修复' },
+  { id: '8', deviceCode:'YJ-JG-001', deviceName:'辰州北路窨井盖', deviceType:'窨井盖', level:'轻微', alarmDesc:'井盖表面沉降3cm,低于路面标高,不影响通行但需定期监测', address:'辰州北路北段人行道', alarmTime:'2026-06-10 08:15:33', processStatus:'已修复' },
+  { id: '9', deviceCode:'GD-FM-004', deviceName:'迎宾东路阀门', deviceType:'阀门', level:'一般', alarmDesc:'阀门操作扭矩偏大,电动执行机构电流异常升高,需润滑维护', address:'迎宾东路东段靠近沅陵大道', alarmTime:'2026-06-09 16:42:18', processStatus:'已修复' },
+  { id: '10', deviceCode:'GD-ZD-002', deviceName:'天宁东路终端', deviceType:'终端节点', level:'严重', alarmDesc:'终端设备离线超过48小时,主备电源均失效,可能遭施工破坏或被盗', address:'天宁东路末端靠近古城路', alarmTime:'2026-06-09 03:08:55', processStatus:'处理中' },
+  { id: '11', deviceCode:'GD-TY-002', deviceName:'古城路调压站', deviceType:'调压器', level:'一般', alarmDesc:'调压站周边有施工活动,振动监测值超标,建议现场确认施工影响范围', address:'古城中路沅陵一中北侧', alarmTime:'2026-06-08 20:35:12', processStatus:'已修复' },
+  { id: '12', deviceCode:'YJ-JG-005', deviceName:'建设路窨井盖', deviceType:'窨井盖', level:'轻微', alarmDesc:'井盖标识磨损严重,编号无法辨识,需重新喷涂或更换标识牌', address:'建设东路商铺门前人行道', alarmTime:'2026-06-08 12:50:40', processStatus:'已修复' },
+  { id: '13', deviceCode:'GD-FM-002', deviceName:'辰州中路阀门', deviceType:'阀门', level:'严重', alarmDesc:'阀门井内积水超过警戒线,排水泵未自动启动,可能淹没电动执行机构', address:'辰州中路与迎宾路交叉口', alarmTime:'2026-06-07 19:22:07', processStatus:'已修复' },
+  { id: '14', deviceCode:'GD-LL-003', deviceName:'建设东路计量柜', deviceType:'流量计', level:'一般', alarmDesc:'计量柜显示屏背光故障,夜间无法读取数据,不影响计量精度', address:'建设东路滨江路口附近', alarmTime:'2026-06-07 09:14:28', processStatus:'已修复' },
+  { id: '15', deviceCode:'YJ-JG-006', deviceName:'滨江路窨井盖', deviceType:'窨井盖', level:'轻微', alarmDesc:'井盖铰链锈蚀松动,开启闭合有异响,建议润滑或更换铰链部件', address:'滨江路沿江步道观景台旁', alarmTime:'2026-06-06 15:30:55', processStatus:'已修复' },
+  { id: '16', deviceCode:'GD-ZD-001', deviceName:'沅陵县政府终端', deviceType:'终端节点', level:'一般', alarmDesc:'终端存储空间使用率达到92%,历史数据清理策略未生效', address:'辰州中街县政府办公大楼', alarmTime:'2026-06-06 08:45:12', processStatus:'已修复' },
+  { id: '17', deviceCode:'YJ-JC-001', deviceName:'辰州路检查井', deviceType:'检查井', level:'一般', alarmDesc:'检查井内可燃气体浓度检测值持续偏高,超标时长超过2小时', address:'辰州中路地下燃气管线上方', alarmTime:'2026-06-05 23:18:40', processStatus:'处理中' },
+  { id: '18', deviceCode:'GD-FM-001', deviceName:'辰州北路阀门', deviceType:'阀门', level:'轻微', alarmDesc:'阀门控制箱密封条老化脱落,雨水渗入痕迹明显,需更换密封件', address:'辰州北路与天宁路交叉口北侧50米', alarmTime:'2026-06-05 10:42:33', processStatus:'已修复' }
+])
+
+const filteredTableData = computed(() => {
+  let data = tableData.value
+  if (searchKey.value) {
+    const kw = searchKey.value.toLowerCase()
+    data = data.filter(row =>
+      row.deviceName.includes(kw) || row.deviceCode.toLowerCase().includes(kw) || row.address.includes(kw)
+    )
+  }
+  if (levelFilter.value) data = data.filter(row => row.level === levelFilter.value)
+  if (typeFilter.value) data = data.filter(row => row.deviceType === typeFilter.value)
+  if (statusFilter.value) data = data.filter(row => row.processStatus === statusFilter.value)
+  return data
+})
+
+const paginatedData = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return filteredTableData.value.slice(start, start + pageSize.value)
+})
+
+// ==================== 工具函数 ====================
+function levelTagType(level) {
+  if (level === '严重') return 'danger'
+  if (level === '一般') return 'warning'
+  return 'info'
+}
+
+function handleQuery() {
+  currentPage.value = 1
+}
+
+function handleReset() {
+  searchKey.value = ''
+  levelFilter.value = ''
+  typeFilter.value = ''
+  statusFilter.value = ''
+  currentPage.value = 1
+}
+
+function handleDetail(row) {
+  ElMessageBox.alert(
+    `<div style="line-height:2.2;font-size:14px;">
+      <p><b>设备编号:</b>${row.deviceCode}</p>
+      <p><b>设备名称:</b>${row.deviceName}</p>
+      <p><b>设备类型:</b>${row.deviceType}</p>
+      <p><b>异常等级:</b><span style="color:${row.level==='严重'?'#e63946':row.level==='一般'?'#f77f00':'#909399'}">${row.level}</span></p>
+      <p><b>异常描述:</b>${row.alarmDesc}</p>
+      <p><b>安装地址:</b>${row.address}</p>
+      <p><b>异常时间:</b>${row.alarmTime}</p>
+      <p><b>处理状态:</b>${row.processStatus}</p>
+    </div>`,
+    '设备异常详情',
+    { dangerouslyUseHTMLString: true, confirmButtonText: '关闭', customClass: 'detail-dialog' }
+  )
+}
+
+function handleProcess(row) {
+  ElMessageBox.confirm(`确认开始处理设备「${row.deviceName}」的异常?系统将自动派发工单至运维班组。`, '处理确认', {
+    confirmButtonText: '确认处理',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(() => {
+    row.processStatus = '处理中'
+    ElMessage.success(`已派发工单,设备「${row.deviceName}」处理中`)
+  }).catch(() => {})
+}
+
+function handleComplete(row) {
+  ElMessageBox.confirm(`确认设备「${row.deviceName}」的异常已修复完成?`, '修复确认', {
+    confirmButtonText: '确认修复',
+    cancelButtonText: '取消',
+    type: 'success'
+  }).then(() => {
+    row.processStatus = '已修复'
+    ElMessage.success(`设备「${row.deviceName}」异常已修复`)
+  }).catch(() => {})
+}
+
+// ==================== ECharts ====================
+function initTrendChart() {
+  const dom = document.getElementById('trendChart')
+  if (!dom) return
+  if (trendChart) trendChart.dispose()
+  trendChart = echarts.init(dom)
+  trendChart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(255,255,255,0.96)',
+      borderColor: '#e0e4ea',
+      textStyle: { color: '#2c3e50', fontSize: 13 }
+    },
+    legend: {
+      data: ['异常总数', '新增异常', '已修复'],
+      top: 0,
+      textStyle: { color: '#606a78', fontSize: 12 }
+    },
+    grid: { left: 52, right: 44, top: 44, bottom: 32 },
+    xAxis: {
+      type: 'category',
+      data: trendDates,
+      boundaryGap: false,
+      axisLabel: { color: '#9098a6', fontSize: 12 },
+      axisLine: { lineStyle: { color: '#d0d5dd' } }
+    },
+    yAxis: {
+      type: 'value',
+      name: '数量(台)',
+      nameTextStyle: { color: '#9098a6', fontSize: 12 },
+      axisLabel: { color: '#9098a6', fontSize: 12 },
+      splitLine: { lineStyle: { color: '#f0f2f6', type: 'dashed' } }
+    },
+    series: [
+      {
+        name: '异常总数', type: 'line',
+        data: trendTotalData, smooth: true, symbol: 'circle', symbolSize: 6,
+        lineStyle: { width: 3, color: '#e63946' },
+        itemStyle: { color: '#e63946', borderColor: '#fff', borderWidth: 2 },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(230,57,70,0.15)' },
+            { offset: 1, color: 'rgba(230,57,70,0.0)' }
+          ])
+        }
+      },
+      {
+        name: '新增异常', type: 'line',
+        data: trendNewData, smooth: true, symbol: 'emptyCircle', symbolSize: 6,
+        lineStyle: { width: 2.5, color: '#f77f00' },
+        itemStyle: { color: '#f77f00', borderColor: '#f77f00', borderWidth: 2 }
+      },
+      {
+        name: '已修复', type: 'line',
+        data: trendProcessedData, smooth: true, symbol: 'diamond', symbolSize: 7,
+        lineStyle: { width: 2.5, color: '#2a9d8f', type: 'dashed' },
+        itemStyle: { color: '#2a9d8f', borderColor: '#fff', borderWidth: 2 }
+      }
+    ]
+  })
+}
+
+function initTypeChart() {
+  const dom = document.getElementById('typeChart')
+  if (!dom) return
+  if (typeChart) typeChart.dispose()
+  typeChart = echarts.init(dom)
+  const categories = ['通讯故障', '设备老化', '外部损坏', '传感器故障', '电源问题', '其他']
+  const values = [8, 7, 5, 4, 3, 1]
+  const colors = ['#457b9d', '#e76f51', '#e63946', '#f77f00', '#6c757d', '#b08968']
+  typeChart.setOption({
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { left: 46, right: 20, top: 16, bottom: 32 },
+    xAxis: {
+      type: 'category',
+      data: categories,
+      axisLabel: { color: '#9098a6', fontSize: 11, rotate: 15 },
+      axisLine: { lineStyle: { color: '#d0d5dd' } }
+    },
+    yAxis: {
+      type: 'value', name: '数量(台)',
+      axisLabel: { color: '#9098a6', fontSize: 11 },
+      splitLine: { lineStyle: { color: '#f0f2f6', type: 'dashed' } }
+    },
+    series: [{
+      type: 'bar',
+      data: values.map((v, i) => ({ value: v, itemStyle: { color: colors[i], borderRadius: [4, 4, 0, 0] } })),
+      barWidth: 28,
+      label: { show: true, position: 'top', color: '#4e5969', fontSize: 12, fontWeight: 600 }
+    }]
+  })
+}
+
+function initLevelChart() {
+  const dom = document.getElementById('levelChart')
+  if (!dom) return
+  if (levelChart) levelChart.dispose()
+  levelChart = echarts.init(dom)
+  levelChart.setOption({
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: {
+      data: ['待处理', '处理中', '已修复'],
+      top: 0,
+      textStyle: { color: '#606a78', fontSize: 12 }
+    },
+    grid: { left: 46, right: 20, top: 44, bottom: 28 },
+    xAxis: {
+      type: 'category',
+      data: ['严重', '一般', '轻微'],
+      axisLabel: { color: '#9098a6', fontSize: 12 },
+      axisLine: { lineStyle: { color: '#d0d5dd' } }
+    },
+    yAxis: {
+      type: 'value', name: '数量(台)',
+      axisLabel: { color: '#9098a6', fontSize: 11 },
+      splitLine: { lineStyle: { color: '#f0f2f6', type: 'dashed' } },
+      nameTextStyle: { color: '#9098a6', fontSize: 11 }
+    },
+    series: [
+      {
+        name: '待处理', type: 'bar',
+        data: [3, 3, 4], barWidth: 30, barGap: '20%',
+        itemStyle: { color: '#e63946', borderRadius: [4, 4, 0, 0] }
+      },
+      {
+        name: '处理中', type: 'bar',
+        data: [2, 3, 0], barWidth: 30,
+        itemStyle: { color: '#f77f00', borderRadius: [4, 4, 0, 0] }
+      },
+      {
+        name: '已修复', type: 'bar',
+        data: [5, 7, 6], barWidth: 30,
+        itemStyle: { color: '#2a9d8f', borderRadius: [4, 4, 0, 0] }
+      }
+    ]
+  })
+}
+
+function initCharts() {
+  try { initTrendChart() } catch (e) { console.warn('趋势图初始化失败:', e) }
+  try { initTypeChart() } catch (e) { console.warn('类型分布图初始化失败:', e) }
+  try { initLevelChart() } catch (e) { console.warn('等级统计图初始化失败:', e) }
+}
+
+function resizeCharts() {
+  trendChart?.resize()
+  typeChart?.resize()
+  levelChart?.resize()
+}
+
+onMounted(async () => {
+  await nextTick()
+  initCharts()
+  window.addEventListener('resize', resizeCharts)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resizeCharts)
+  trendChart?.dispose()
+  typeChart?.dispose()
+  levelChart?.dispose()
+})
+</script>
+
+<style scoped>
+.equipment-abnormal {
+  display: flex;
+  gap: 16px;
+  padding: 16px;
+  background: #f0f2f6;
+  min-height: calc(100vh - 84px);
+}
+
+/* ========== 左侧面板 ========== */
+.left-panel {
+  width: 280px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.panel-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px;
+  box-shadow: 0 2px 12px rgba(15, 35, 95, 0.04);
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: #1f2d3d;
+  margin-bottom: 14px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid #f0f2f6;
+}
+
+.panel-title .el-icon { color: #1890ff; }
+
+/* 等级列表 */
+.level-list { display: flex; flex-direction: column; gap: 8px; }
+.level-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  border-radius: 8px;
+  background: #f8fafc;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.level-item:hover { background: #eef2f8; transform: translateX(4px); }
+.level-dot { width: 10px; height: 10px; border-radius: 50%; }
+.level-item.critical .level-dot { background: #e63946; box-shadow: 0 0 6px rgba(230,57,70,0.4); }
+.level-item.warning .level-dot { background: #f77f00; box-shadow: 0 0 6px rgba(247,127,0,0.4); }
+.level-item.minor .level-dot { background: #457b9d; box-shadow: 0 0 6px rgba(69,123,157,0.4); }
+.level-label { flex: 1; font-size: 13px; color: #4e5969; }
+.level-count { font-size: 18px; font-weight: 700; color: #1f2d3d; }
+
+/* 设备类型列表 */
+.type-list { display: flex; flex-direction: column; gap: 10px; }
+.type-item { display: flex; align-items: center; gap: 10px; }
+.type-name { width: 56px; font-size: 12px; color: #606a78; text-align: right; flex-shrink: 0; }
+.type-bar-bg { flex: 1; height: 8px; background: #f0f2f6; border-radius: 4px; overflow: hidden; }
+.type-bar { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
+.type-count { width: 24px; font-size: 12px; font-weight: 600; color: #1f2d3d; text-align: right; flex-shrink: 0; }
+
+/* ========== 右侧内容区 ========== */
+.right-panel {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-width: 0;
+}
+
+/* 统计卡片 */
+.stats-row {
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
+}
+
+.stat-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 14px 16px;
+  box-shadow: 0 2px 12px rgba(15, 35, 95, 0.04);
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  cursor: pointer;
+  transition: all 0.25s;
+}
+.stat-card:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(15, 35, 95, 0.1);
+}
+
+.stat-icon {
+  width: 44px; height: 44px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.stat-total .stat-icon { background: #fef0f0; color: #e63946; }
+.stat-today .stat-icon { background: #fff3e6; color: #f77f00; }
+.stat-rate .stat-icon { background: #fef5e7; color: #e76f51; }
+.stat-processed .stat-icon { background: #e8f8f5; color: #2a9d8f; }
+.stat-pending .stat-icon { background: #eaf2f8; color: #457b9d; }
+.stat-process-rate .stat-icon { background: #e8f5e9; color: #2d6a4f; }
+
+.stat-label { font-size: 12px; color: #9098a6; margin-bottom: 4px; }
+.stat-value { font-size: 24px; font-weight: 700; color: #1f2d3d; line-height: 1.1; }
+.stat-value small { font-size: 12px; color: #9098a6; font-weight: 400; }
+
+/* 图表卡片 */
+.chart-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px 20px;
+  box-shadow: 0 2px 12px rgba(15, 35, 95, 0.04);
+  display: flex;
+  flex-direction: column;
+}
+
+.chart-trend { /* 全宽 */ }
+
+.charts-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+}
+.chart-half { flex: 1; }
+
+.chart-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 6px;
+  flex-wrap: wrap;
+}
+
+.chart-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2d3d;
+}
+
+.chart-subtitle {
+  font-size: 12px;
+  color: #a0a9b6;
+}
+
+.chart-legend-custom {
+  margin-left: auto;
+  display: flex;
+  gap: 8px;
+}
+
+.legend-tag {
+  font-size: 11px;
+  padding: 2px 10px;
+  border-radius: 10px;
+  color: #fff;
+}
+.tag-total { background: #e63946; }
+.tag-new { background: #f77f00; }
+.tag-fixed { background: #2a9d8f; }
+
+.chart-body {
+  flex: 1;
+  min-height: 280px;
+}
+.chart-body-large {
+  min-height: 330px;
+}
+
+/* 表格卡片 */
+.table-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px 20px;
+  box-shadow: 0 2px 12px rgba(15, 35, 95, 0.04);
+}
+
+.table-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.table-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2d3d;
+}
+
+.table-count {
+  font-size: 12px;
+  color: #9098a6;
+}
+
+.table-footer {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+
+/* ========== 响应式 ========== */
+@media (max-width: 1600px) {
+  .stats-row { grid-template-columns: repeat(3, 1fr); }
+  .left-panel { width: 260px; }
+}
+
+@media (max-width: 1400px) {
+  .equipment-abnormal { flex-direction: column; }
+  .left-panel { width: 100%; flex-direction: row; flex-wrap: wrap; }
+  .left-panel .panel-card { flex: 1; min-width: 240px; }
+  .charts-row { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 768px) {
+  .stats-row { grid-template-columns: repeat(2, 1fr); }
+}
+</style>

+ 1326 - 0
src/views/subSystem/basic/GisDashboard.vue

@@ -0,0 +1,1326 @@
+<template>
+  <div class="gis-dashboard">
+    <div class="dashboard-header">
+      <div class="header-title">
+        <div class="title-main">
+          <el-icon><MapLocation /></el-icon>
+          <span>燃气管网GIS一张图</span>
+        </div>
+        <div class="title-metrics">
+          <span class="metric metric-danger">管线 {{ summaryStats.pipelineLength }}km</span>
+          <span class="metric metric-primary">燃气管点 {{ summaryStats.pipePointCount }}个</span>
+          <span class="metric metric-warning">窨井 {{ summaryStats.manholeCount }}个</span>
+        </div>
+      </div>
+      <div class="header-actions">
+        <el-input
+          v-model="searchKeyword"
+          class="search-input"
+          placeholder="搜索管点/井盖编号/位置"
+          clearable
+          @keyup.enter="handleSearch"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+        <el-button type="primary" @click="handleSearch">
+          <el-icon><Search /></el-icon>
+          搜索
+        </el-button>
+        <el-button @click="refreshScene">
+          <el-icon><RefreshRight /></el-icon>
+          刷新
+        </el-button>
+        <el-button @click="toggleFullscreen">
+          <el-icon><FullScreen /></el-icon>
+          全图
+        </el-button>
+      </div>
+    </div>
+
+    <div ref="screenRef" class="dashboard-content">
+      <aside class="dashboard-sidebar">
+        <el-card shadow="never" class="panel-card">
+          <template #header>
+            <span>图层控制</span>
+          </template>
+          <div class="layer-group">
+            <div class="layer-title">燃气管线</div>
+            <el-checkbox v-model="layerState.pipelines.high">高压管线</el-checkbox>
+            <el-checkbox v-model="layerState.pipelines.subHigh">次高压管线</el-checkbox>
+            <el-checkbox v-model="layerState.pipelines.medium">中压管线</el-checkbox>
+            <el-checkbox v-model="layerState.pipelines.low">低压管线</el-checkbox>
+          </div>
+          <div class="layer-group">
+            <div class="layer-title">燃气管点</div>
+            <el-checkbox v-model="layerState.pipePoints.valve">阀门</el-checkbox>
+            <el-checkbox v-model="layerState.pipePoints.regulator">调压器</el-checkbox>
+            <el-checkbox v-model="layerState.pipePoints.flowmeter">流量计</el-checkbox>
+            <el-checkbox v-model="layerState.pipePoints.terminal">终端节点</el-checkbox>
+          </div>
+          <div class="layer-group layer-group-last">
+            <div class="layer-title">窨井设施</div>
+            <el-checkbox v-model="layerState.manholes.cover">窨井盖</el-checkbox>
+            <el-checkbox v-model="layerState.manholes.inspection">检查井</el-checkbox>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="panel-card">
+          <template #header>
+            <span>图例说明</span>
+          </template>
+          <div class="legend-list">
+            <div class="legend-item"><span class="legend-line legend-high"></span><span>高压管线</span></div>
+            <div class="legend-item"><span class="legend-line legend-sub-high"></span><span>次高压管线</span></div>
+            <div class="legend-item"><span class="legend-line legend-medium"></span><span>中压管线</span></div>
+            <div class="legend-item"><span class="legend-line legend-low"></span><span>低压管线</span></div>
+            <div class="legend-item"><span class="legend-dot legend-valve"></span><span>阀门</span></div>
+            <div class="legend-item"><span class="legend-dot legend-regulator"></span><span>调压器</span></div>
+            <div class="legend-item"><span class="legend-dot legend-flowmeter"></span><span>流量计</span></div>
+            <div class="legend-item"><span class="legend-dot legend-terminal"></span><span>终端节点</span></div>
+            <div class="legend-item"><span class="legend-square legend-cover"></span><span>窨井盖</span></div>
+            <div class="legend-item"><span class="legend-square legend-inspection"></span><span>检查井</span></div>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="panel-card stats-card">
+          <template #header>
+            <span>测绘数据统计</span>
+          </template>
+          <div class="stats-list">
+            <div class="stats-row"><span>管线总长</span><strong>{{ summaryStats.pipelineLength }} km</strong></div>
+            <div class="stats-row"><span>管点总数</span><strong>{{ summaryStats.pipePointCount }}</strong></div>
+            <div class="stats-row"><span>窨井总数</span><strong>{{ summaryStats.manholeCount }}</strong></div>
+            <div class="stats-row"><span>覆盖面积</span><strong>{{ summaryStats.coverageArea }} km²</strong></div>
+          </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">
+        <div class="map-toolbar">
+          <div class="toolbar-left">
+            <el-button @click="zoomIn">
+              <el-icon><Plus /></el-icon>
+              放大
+            </el-button>
+            <el-button @click="zoomOut">
+              <el-icon><Minus /></el-icon>
+              缩小
+            </el-button>
+            <el-button @click="locateCenter">
+              <el-icon><Location /></el-icon>
+              定位
+            </el-button>
+          </div>
+          <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>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { FullScreen, Location, MapLocation, Minus, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const centerPoint = { lng: 110.3937, lat: 28.4640 }
+
+const MAP_BOUNDS = {
+  southWest: { lng: 110.365, lat: 28.452 },
+  northEast: { lng: 110.420, lat: 28.482 }
+}
+const MAP_MIN_ZOOM = 13
+const MAP_MAX_ZOOM = 19
+const MAP_DEFAULT_ZOOM = 15
+
+const defaultLayerState = () => ({
+  pipelines: { high: true, subHigh: true, medium: true, low: true },
+  pipePoints: { valve: true, regulator: true, flowmeter: true, terminal: true },
+  manholes: { cover: true, inspection: true }
+})
+
+const boundaryPoints = [
+  { lng: 110.368, lat: 28.478 },
+  { lng: 110.385, lat: 28.480 },
+  { lng: 110.402, lat: 28.480 },
+  { lng: 110.416, lat: 28.476 },
+  { lng: 110.418, lat: 28.466 },
+  { lng: 110.415, lat: 28.459 },
+  { lng: 110.408, lat: 28.455 },
+  { lng: 110.395, lat: 28.456 },
+  { lng: 110.382, lat: 28.458 },
+  { lng: 110.372, lat: 28.462 },
+  { lng: 110.367, lat: 28.470 }
+]
+
+const pipelineData = [
+  {
+    id: 'pipe-high-01',
+    level: 'high',
+    name: '辰州路高压燃气管线',
+    code: 'GX-HP-001',
+    length: 4.1,
+    material: 'L290N 无缝钢管',
+    diameter: 'DN300',
+    pressure: '高压A (4.0MPa)',
+    road: '辰州北路 — 辰州中路',
+    status: '运行正常',
+    layingMethod: '直埋敷设',
+    depth: '1.8m',
+    commissioningDate: '2026-06',
+    points: [
+      { lng: 110.3918, lat: 28.4788 },
+      { lng: 110.3920, lat: 28.4765 },
+      { lng: 110.3924, lat: 28.4743 },
+      { lng: 110.3927, lat: 28.4721 },
+      { lng: 110.3931, lat: 28.4698 },
+      { lng: 110.3934, lat: 28.4675 },
+      { lng: 110.3936, lat: 28.4652 },
+      { lng: 110.3937, lat: 28.4630 },
+      { lng: 110.3935, lat: 28.4607 },
+      { lng: 110.3934, lat: 28.4585 },
+      { lng: 110.3935, lat: 28.4576 }
+    ]
+  },
+  {
+    id: 'pipe-subhigh-01',
+    level: 'subHigh',
+    name: '古城路次高压管线',
+    code: 'GX-SHP-002',
+    length: 3.8,
+    material: 'L245N 无缝钢管',
+    diameter: 'DN250',
+    pressure: '次高压A (1.6MPa)',
+    road: '古城北路 — 古城中路',
+    status: '运行正常',
+    layingMethod: '直埋敷设',
+    depth: '1.6m',
+    commissioningDate: '2026-03',
+    points: [
+      { lng: 110.3980, lat: 28.4790 },
+      { lng: 110.3979, lat: 28.4765 },
+      { lng: 110.3977, lat: 28.4741 },
+      { lng: 110.3975, lat: 28.4717 },
+      { lng: 110.3973, lat: 28.4693 },
+      { lng: 110.3970, lat: 28.4669 },
+      { lng: 110.3968, lat: 28.4645 },
+      { lng: 110.3965, lat: 28.4622 },
+      { lng: 110.3963, lat: 28.4598 },
+      { lng: 110.3962, lat: 28.4575 }
+    ]
+  },
+  {
+    id: 'pipe-medium-01',
+    level: 'medium',
+    name: '迎宾路中压管线',
+    code: 'GX-MP-003',
+    length: 5.5,
+    material: 'PE100 SDR11',
+    diameter: 'DN200',
+    pressure: '中压A (0.4MPa)',
+    road: '迎宾西路 — 迎宾东路',
+    status: '运行正常',
+    layingMethod: '直埋敷设',
+    depth: '1.4m',
+    commissioningDate: '2025-09',
+    points: [
+      { lng: 110.3732, lat: 28.4650 },
+      { lng: 110.3778, lat: 28.4648 },
+      { lng: 110.3824, lat: 28.4645 },
+      { lng: 110.3870, lat: 28.4642 },
+      { lng: 110.3916, lat: 28.4640 },
+      { lng: 110.3962, lat: 28.4637 },
+      { lng: 110.4008, lat: 28.4634 },
+      { lng: 110.4054, lat: 28.4630 },
+      { lng: 110.4098, lat: 28.4627 },
+      { lng: 110.4120, lat: 28.4625 }
+    ]
+  },
+  {
+    id: 'pipe-medium-02',
+    level: 'medium',
+    name: '建设路中压管线',
+    code: 'GX-MP-004',
+    length: 4.8,
+    material: 'PE100 SDR11',
+    diameter: 'DN160',
+    pressure: '中压B (0.2MPa)',
+    road: '建设西路 — 建设东路',
+    status: '运行正常',
+    layingMethod: '定向钻穿越',
+    depth: '1.5m',
+    commissioningDate: '2025-04',
+    points: [
+      { lng: 110.3748, lat: 28.4583 },
+      { lng: 110.3794, lat: 28.4586 },
+      { lng: 110.3840, lat: 28.4588 },
+      { lng: 110.3886, lat: 28.4589 },
+      { lng: 110.3912, lat: 28.4586 },
+      { lng: 110.3937, lat: 28.4582 },
+      { lng: 110.3966, lat: 28.4579 },
+      { lng: 110.4002, lat: 28.4581 },
+      { lng: 110.4038, lat: 28.4578 },
+      { lng: 110.4076, lat: 28.4575 }
+    ]
+  },
+  {
+    id: 'pipe-low-01',
+    level: 'low',
+    name: '滨江路低压管线',
+    code: 'GX-LP-005',
+    length: 4.0,
+    material: 'PE80 SDR17.6',
+    diameter: 'DN110',
+    pressure: '低压 (5kPa)',
+    road: '滨江路 — 沿江北岸',
+    status: '运行正常',
+    layingMethod: '直埋敷设',
+    depth: '1.2m',
+    commissioningDate: '2025-11',
+    points: [
+      { lng: 110.3762, lat: 28.4569 },
+      { lng: 110.3804, lat: 28.4570 },
+      { lng: 110.3846, lat: 28.4571 },
+      { lng: 110.3882, lat: 28.4568 },
+      { lng: 110.3918, lat: 28.4565 },
+      { lng: 110.3954, lat: 28.4562 },
+      { lng: 110.3990, lat: 28.4560 },
+      { lng: 110.4028, lat: 28.4559 },
+      { lng: 110.4066, lat: 28.4557 },
+      { lng: 110.4092, lat: 28.4556 }
+    ]
+  },
+  {
+    id: 'pipe-low-02',
+    level: 'low',
+    name: '天宁路低压管线',
+    code: 'GX-LP-006',
+    length: 3.5,
+    material: 'PE80 SDR17.6',
+    diameter: 'DN90',
+    pressure: '低压 (3kPa)',
+    road: '天宁西路 — 天宁东路',
+    status: '运行正常',
+    layingMethod: '直埋敷设',
+    depth: '1.0m',
+    commissioningDate: '2026-05',
+    points: [
+      { lng: 110.3828, lat: 28.4704 },
+      { lng: 110.3870, lat: 28.4702 },
+      { lng: 110.3912, lat: 28.4700 },
+      { lng: 110.3954, lat: 28.4697 },
+      { lng: 110.3996, lat: 28.4695 },
+      { lng: 110.4038, lat: 28.4693 },
+      { lng: 110.4078, lat: 28.4691 },
+      { lng: 110.4092, lat: 28.4690 }
+    ]
+  }
+]
+
+const pipePointData = [
+  {
+    id: 'point-valve-01', type: 'valve', pipelineId: 'pipe-high-01',
+    name: '辰州北路阀门', code: 'GD-FM-001', road: '辰州北路',
+    status: '在线', deviceModel: 'Z45X-16Q', caliber: 'DN300',
+    lng: 110.3924, lat: 28.4743
+  },
+  {
+    id: 'point-valve-02', type: 'valve', pipelineId: 'pipe-high-01',
+    name: '辰州中路阀门', code: 'GD-FM-002', road: '辰州中路',
+    status: '在线', deviceModel: 'Z41H-25C', caliber: 'DN300',
+    lng: 110.3936, lat: 28.4652
+  },
+  {
+    id: 'point-valve-03', type: 'valve', pipelineId: 'pipe-subhigh-01',
+    name: '古城北路阀门', code: 'GD-FM-003', road: '古城北路',
+    status: '在线', deviceModel: 'Z45X-16Q', caliber: 'DN250',
+    lng: 110.3977, lat: 28.4741
+  },
+  {
+    id: 'point-valve-04', type: 'valve', pipelineId: 'pipe-medium-01',
+    name: '迎宾东路阀门', code: 'GD-FM-004', road: '迎宾东路',
+    status: '在线', deviceModel: 'Z41H-16C', caliber: 'DN200',
+    lng: 110.4054, lat: 28.4630
+  },
+  {
+    id: 'point-regulator-01', type: 'regulator', pipelineId: 'pipe-high-01',
+    name: '辰州路调压站', code: 'GD-TY-001', road: '辰州中路',
+    status: '在线', deviceModel: 'RTZ-80/0.4F', caliber: 'DN300→DN200',
+    lng: 110.3934, lat: 28.4585
+  },
+  {
+    id: 'point-regulator-02', type: 'regulator', pipelineId: 'pipe-subhigh-01',
+    name: '古城路调压站', code: 'GD-TY-002', road: '古城中路',
+    status: '在线', deviceModel: 'RTZ-50/0.4F', caliber: 'DN250→DN160',
+    lng: 110.3963, lat: 28.4598
+  },
+  {
+    id: 'point-regulator-03', type: 'regulator', pipelineId: 'pipe-medium-02',
+    name: '建设路调压柜', code: 'GD-TY-003', road: '建设西路',
+    status: '在线', deviceModel: 'RTZ-31/0.4Q', caliber: 'DN160→DN110',
+    lng: 110.3840, lat: 28.4588
+  },
+  {
+    id: 'point-flowmeter-01', type: 'flowmeter', pipelineId: 'pipe-high-01',
+    name: '辰州北路计量站', code: 'GD-LL-001', road: '辰州北路',
+    status: '在线', deviceModel: 'LWQZ-150', caliber: 'DN300',
+    lng: 110.3927, lat: 28.4721
+  },
+  {
+    id: 'point-flowmeter-02', type: 'flowmeter', pipelineId: 'pipe-medium-01',
+    name: '迎宾西路计量站', code: 'GD-LL-002', road: '迎宾西路',
+    status: '在线', deviceModel: 'LWQZ-100', caliber: 'DN200',
+    lng: 110.3824, lat: 28.4645
+  },
+  {
+    id: 'point-flowmeter-03', type: 'flowmeter', pipelineId: 'pipe-medium-02',
+    name: '建设东路计量柜', code: 'GD-LL-003', road: '建设东路',
+    status: '在线', deviceModel: 'LWQZ-80', caliber: 'DN110',
+    lng: 110.4038, lat: 28.4578
+  },
+  {
+    id: 'point-terminal-01', type: 'terminal', pipelineId: 'pipe-medium-02',
+    name: '沅陵县政府终端', code: 'GD-ZD-001', road: '辰州中街',
+    status: '在线', deviceModel: 'RTU-GPRS-4G', caliber: '—',
+    lng: 110.3937, lat: 28.4582
+  },
+  {
+    id: 'point-terminal-02', type: 'terminal', pipelineId: 'pipe-low-02',
+    name: '天宁东路终端', code: 'GD-ZD-002', road: '天宁东路',
+    status: '在线', deviceModel: 'RTU-GPRS-4G', caliber: '—',
+    lng: 110.4092, lat: 28.4690
+  }
+]
+
+const manholeData = [
+  {
+    id: 'manhole-cover-01', type: 'cover', pipelineId: 'pipe-high-01',
+    name: '辰州北路窨井盖', code: 'YJ-JG-001', road: '辰州北路',
+    status: '完好', material: '球墨铸铁', size: 'Φ700',
+    lng: 110.3920, lat: 28.4765
+  },
+  {
+    id: 'manhole-cover-02', type: 'cover', pipelineId: 'pipe-high-01',
+    name: '辰州中路窨井盖', code: 'YJ-JG-002', road: '辰州中路',
+    status: '完好', material: '球墨铸铁', size: 'Φ700',
+    lng: 110.3935, lat: 28.4607
+  },
+  {
+    id: 'manhole-cover-03', type: 'cover', pipelineId: 'pipe-subhigh-01',
+    name: '古城北路窨井盖', code: 'YJ-JG-003', road: '古城北路',
+    status: '完好', material: '复合树脂', size: 'Φ700',
+    lng: 110.3979, lat: 28.4765
+  },
+  {
+    id: 'manhole-cover-04', type: 'cover', pipelineId: 'pipe-medium-01',
+    name: '迎宾路窨井盖', code: 'YJ-JG-004', road: '迎宾中路',
+    status: '轻微破损', material: '球墨铸铁', size: 'Φ700',
+    lng: 110.3962, lat: 28.4637
+  },
+  {
+    id: 'manhole-cover-05', type: 'cover', pipelineId: 'pipe-medium-02',
+    name: '建设路窨井盖', code: 'YJ-JG-005', road: '建设东路',
+    status: '完好', material: '球墨铸铁', size: 'Φ600',
+    lng: 110.4002, lat: 28.4581
+  },
+  {
+    id: 'manhole-cover-06', type: 'cover', pipelineId: 'pipe-low-01',
+    name: '滨江路窨井盖', code: 'YJ-JG-006', road: '滨江路',
+    status: '完好', material: '复合树脂', size: 'Φ700',
+    lng: 110.3954, lat: 28.4562
+  },
+  {
+    id: 'manhole-inspection-01', type: 'inspection', pipelineId: 'pipe-high-01',
+    name: '辰州路检查井', code: 'YJ-JC-001', road: '辰州中路',
+    status: '巡检正常', material: '钢筋混凝土', size: '1000×1000',
+    lng: 110.3934, lat: 28.4675
+  },
+  {
+    id: 'manhole-inspection-02', type: 'inspection', pipelineId: 'pipe-subhigh-01',
+    name: '古城路检查井', code: 'YJ-JC-002', road: '古城中路',
+    status: '巡检正常', material: '钢筋混凝土', size: '1200×1000',
+    lng: 110.3970, lat: 28.4669
+  },
+  {
+    id: 'manhole-inspection-03', type: 'inspection', pipelineId: 'pipe-low-02',
+    name: '天宁路检查井', code: 'YJ-JC-003', road: '天宁西路',
+    status: '需维护', material: '钢筋混凝土', size: '1000×1000',
+    lng: 110.3954, lat: 28.4697
+  }
+]
+
+const colorMap = {
+  high: '#e63946',
+  subHigh: '#f77f00',
+  medium: '#fcbf49',
+  low: '#2a9d8f',
+  valve: '#457b9d',
+  regulator: '#e76f51',
+  flowmeter: '#2d6a4f',
+  terminal: '#6c757d',
+  cover: '#7f4f24',
+  inspection: '#b08968'
+}
+
+const labelMap = {
+  high: '高压管线',
+  subHigh: '次高压管线',
+  medium: '中压管线',
+  low: '低压管线',
+  valve: '阀门',
+  regulator: '调压器',
+  flowmeter: '流量计',
+  terminal: '终端节点',
+  cover: '窨井盖',
+  inspection: '检查井'
+}
+
+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 layerState = reactive(defaultLayerState())
+
+let mapInstance = null
+let activeInfoWindow = null
+let overlayClicked = false
+
+const summaryStats = computed(() => ({
+  pipelineLength: pipelineData.reduce((sum, item) => sum + item.length, 0).toFixed(1),
+  pipePointCount: pipePointData.length,
+  manholeCount: manholeData.length,
+  coverageArea: '18.6'
+}))
+
+const selectedFeatureDetails = computed(() => {
+  if (!selectedFeature.value) return []
+  return Object.entries(selectedFeature.value.details).map(([label, value]) => ({ label, value }))
+})
+
+const searchableFeatures = computed(() => {
+  const pipelines = pipelineData.map(item => ({
+    kind: 'pipeline',
+    type: item.level,
+    name: item.name,
+    code: item.code,
+    road: item.road,
+    lng: getMidPoint(item.points).lng,
+    lat: getMidPoint(item.points).lat,
+    raw: item
+  }))
+  const pipePoints = pipePointData.map(item => ({
+    kind: 'pipePoint',
+    type: item.type,
+    name: item.name,
+    code: item.code,
+    road: item.road,
+    lng: item.lng,
+    lat: item.lat,
+    raw: item
+  }))
+  const manholes = manholeData.map(item => ({
+    kind: 'manhole',
+    type: item.type,
+    name: item.name,
+    code: item.code,
+    road: item.road,
+    lng: item.lng,
+    lat: item.lat,
+    raw: item
+  }))
+  return [...pipelines, ...pipePoints, ...manholes]
+})
+
+watch(layerState, () => {
+  renderMapScene()
+}, { deep: true })
+
+onMounted(async () => {
+  await nextTick()
+  waitForBMapGL()
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
+})
+
+onBeforeUnmount(() => {
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
+  if (mapInstance) {
+    mapInstance.clearOverlays()
+    mapInstance = null
+  }
+})
+
+function waitForBMapGL() {
+  if (typeof BMapGL !== 'undefined') {
+    initMap()
+    return
+  }
+
+  let attempts = 0
+  const maxAttempts = 50
+  const timer = setInterval(() => {
+    attempts++
+    if (typeof BMapGL !== 'undefined') {
+      clearInterval(timer)
+      initMap()
+      return
+    }
+    if (attempts >= maxAttempts) {
+      clearInterval(timer)
+      mapError.value = '百度地图脚本加载超时,请刷新页面重试。'
+    }
+  }, 200)
+}
+
+function initMap() {
+  const el = document.getElementById('gisMap')
+  if (!el) {
+    mapError.value = '地图容器未找到。'
+    return
+  }
+
+  try {
+    mapInstance = new BMapGL.Map('gisMap', { enableMapClick: true })
+  } catch (e) {
+    console.error('创建地图实例失败:', e)
+    mapError.value = '百度地图初始化失败,请检查AK和网络连接。'
+    return
+  }
+
+  try {
+    const centerBMapPoint = new BMapGL.Point(centerPoint.lng, centerPoint.lat)
+    mapInstance.centerAndZoom(centerBMapPoint, MAP_DEFAULT_ZOOM)
+    mapInstance.enableScrollWheelZoom(true)
+  } catch (e) {
+    console.error('设置地图中心失败:', e)
+  }
+
+  try {
+    if (typeof mapInstance.setMinZoom === 'function') {
+      mapInstance.setMinZoom(MAP_MIN_ZOOM)
+    }
+    if (typeof mapInstance.setMaxZoom === 'function') {
+      mapInstance.setMaxZoom(MAP_MAX_ZOOM)
+    }
+  } catch (e) {
+    console.warn('设置缩放限制失败:', e)
+  }
+
+  try {
+    if (typeof mapInstance.setMaxBounds === 'function') {
+      const sw = new BMapGL.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
+      const ne = new BMapGL.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
+      mapInstance.setMaxBounds(new BMapGL.Bounds(sw, ne))
+    }
+  } catch (e) {
+    console.warn('设置地图范围限制失败:', e)
+  }
+
+  mapInstance.addEventListener('click', () => {
+    if (overlayClicked) {
+      overlayClicked = false
+      return
+    }
+    closeInfoWindow()
+    selectedFeature.value = null
+  })
+
+  mapInstance.addEventListener('mousemove', (event) => {
+    try {
+      if (event && event.latlng) {
+        currentCoordinate.value = `${event.latlng.lng.toFixed(4)}, ${event.latlng.lat.toFixed(4)}`
+      }
+    } catch (_) { /* ignore mousemove errors */ }
+  })
+
+  try {
+    renderMapScene()
+  } catch (e) {
+    console.error('渲染地图要素失败:', e)
+    mapError.value = '地图要素渲染失败。'
+  }
+}
+
+function renderMapScene() {
+  if (!mapInstance || typeof BMapGL === 'undefined') return
+
+  closeInfoWindow()
+  try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
+  try { drawBoundary() } catch (e) { console.warn('绘制围栏失败:', e) }
+  try { drawPipelines() } catch (e) { console.warn('绘制管线失败:', e) }
+  try { drawPipePoints() } catch (e) { console.warn('绘制管点失败:', e) }
+  try { drawManholes() } catch (e) { console.warn('绘制窨井失败:', e) }
+}
+
+function drawBoundary() {
+  const pts = boundaryPoints.map(item => new BMapGL.Point(item.lng, item.lat))
+  const polygon = new BMapGL.Polygon(pts, {
+    strokeColor: '#52b788',
+    strokeWeight: 2.5,
+    strokeOpacity: 0.85,
+    strokeStyle: 'dashed',
+    fillColor: '#d8f3dc',
+    fillOpacity: 0.22
+  })
+  mapInstance.addOverlay(polygon)
+}
+
+function drawPipelines() {
+  pipelineData.forEach(item => {
+    try {
+      if (!layerState.pipelines[item.level]) return
+      const pts = item.points.map(p => new BMapGL.Point(p.lng, p.lat))
+      const polyline = new BMapGL.Polyline(pts, {
+        strokeColor: colorMap[item.level],
+        strokeWeight: 6,
+        strokeOpacity: 0.92
+      })
+      try {
+        polyline.addEventListener('click', (e) => {
+          overlayClicked = true
+          e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+          showPopup(item.points[0].lng, item.points[0].lat, buildPopupHTML(buildPipelineDetail(item)))
+          selectedFeature.value = buildPipelineDetail(item)
+        })
+      } catch (_) { /* polyline click listener failed */ }
+      mapInstance.addOverlay(polyline)
+
+      const mid = getMidPoint(item.points)
+      addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.level], item)
+    } catch (e) {
+      console.warn(`绘制管线 ${item.name} 失败:`, e)
+    }
+  })
+}
+
+function drawPipePoints() {
+  pipePointData.forEach(item => {
+    try {
+      if (!layerState.pipePoints[item.type]) return
+      addPointMarker(item.lng, item.lat, colorMap[item.type], item.name, 'dot', () => {
+        showPopup(item.lng, item.lat, buildPopupHTML(buildPipePointDetail(item)))
+        selectedFeature.value = buildPipePointDetail(item)
+      })
+    } catch (e) {
+      console.warn(`绘制管点 ${item.name} 失败:`, e)
+    }
+  })
+}
+
+function drawManholes() {
+  manholeData.forEach(item => {
+    try {
+      if (!layerState.manholes[item.type]) return
+      addPointMarker(item.lng, item.lat, colorMap[item.type], item.name, 'square', () => {
+        showPopup(item.lng, item.lat, buildPopupHTML(buildManholeDetail(item)))
+        selectedFeature.value = buildManholeDetail(item)
+      })
+    } catch (e) {
+      console.warn(`绘制窨井 ${item.name} 失败:`, e)
+    }
+  })
+}
+
+function addPipeLabel(lng, lat, text, color, rawItem) {
+  const point = new BMapGL.Point(lng, lat)
+  const html = `<div class="pipe-label" style="color:${color}">${text}</div>`
+  const label = new BMapGL.Label(html, {
+    position: point,
+    offset: new BMapGL.Size(-28, -8)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  try {
+    label.addEventListener('click', (e) => {
+      overlayClicked = true
+      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+      showPopup(lng, lat, buildPopupHTML(buildPipelineDetail(rawItem)))
+      selectedFeature.value = buildPipelineDetail(rawItem)
+    })
+  } catch (_) { /* label click listener failed */ }
+  mapInstance.addOverlay(label)
+}
+
+function addPointMarker(lng, lat, color, name, shape, onClick) {
+  const point = new BMapGL.Point(lng, lat)
+  const size = 22
+  const borderRadius = shape === 'square' ? '3px' : '50%'
+  const html = `<div class="marker-wrap" style="cursor:pointer">
+    <div style="width:${size}px;height:${size}px;background:${color};border-radius:${borderRadius};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);margin:0 auto;"></div>
+    <div class="marker-text">${name}</div>
+  </div>`
+  const label = new BMapGL.Label(html, {
+    position: point,
+    offset: new BMapGL.Size(-size / 2 - 3, -size - 8)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  try {
+    label.addEventListener('click', (e) => {
+      overlayClicked = true
+      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+      onClick && onClick()
+    })
+  } catch (_) { /* marker click listener failed */ }
+  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) {
+  return {
+    name: item.name,
+    category: labelMap[item.level],
+    details: {
+      '管线编号': item.code,
+      '所属道路': item.road,
+      '管线长度': `${item.length} km`,
+      '管径': item.diameter,
+      '材质': item.material,
+      '压力等级': item.pressure,
+      '敷设方式': item.layingMethod,
+      '埋深': item.depth,
+      '投产日期': item.commissioningDate,
+      '运行状态': item.status
+    }
+  }
+}
+
+function getPipelineName(pipelineId) {
+  const found = pipelineData.find(p => p.id === pipelineId)
+  return found ? found.name : '—'
+}
+
+function buildPipePointDetail(item) {
+  return {
+    name: item.name,
+    category: labelMap[item.type],
+    details: {
+      '设备编号': item.code,
+      '所属管线': getPipelineName(item.pipelineId),
+      '所属道路': item.road,
+      '设备型号': item.deviceModel,
+      '口径': item.caliber,
+      '运行状态': item.status,
+      '经度': item.lng.toFixed(5),
+      '纬度': item.lat.toFixed(5)
+    }
+  }
+}
+
+function buildManholeDetail(item) {
+  return {
+    name: item.name,
+    category: labelMap[item.type],
+    details: {
+      '井盖编号': item.code,
+      '所属管线': getPipelineName(item.pipelineId),
+      '所属道路': item.road,
+      '材质': item.material,
+      '规格': item.size,
+      '巡检状态': item.status,
+      '经度': item.lng.toFixed(5),
+      '纬度': item.lat.toFixed(5)
+    }
+  }
+}
+
+function getMidPoint(points) {
+  return points[Math.floor(points.length / 2)]
+}
+
+function handleSearch() {
+  const keyword = searchKeyword.value.trim()
+  if (!keyword) {
+    ElMessage.warning('请输入搜索关键字')
+    return
+  }
+
+  const matched = searchableFeatures.value.find(item =>
+    [item.name, item.code, item.road].some(text => text?.includes(keyword))
+  )
+
+  if (!matched) {
+    ElMessage.info('未找到匹配要素')
+    return
+  }
+
+  mapInstance.centerAndZoom(new BMapGL.Point(matched.lng, matched.lat), 18)
+
+  if (matched.kind === 'pipeline') {
+    selectedFeature.value = buildPipelineDetail(matched.raw)
+    showPopup(matched.lng, matched.lat, buildPopupHTML(selectedFeature.value))
+  } else if (matched.kind === 'pipePoint') {
+    selectedFeature.value = buildPipePointDetail(matched.raw)
+    showPopup(matched.lng, matched.lat, buildPopupHTML(selectedFeature.value))
+  } else {
+    selectedFeature.value = buildManholeDetail(matched.raw)
+    showPopup(matched.lng, matched.lat, buildPopupHTML(selectedFeature.value))
+  }
+
+  ElMessage.success(`已定位到 ${matched.name}`)
+}
+
+function refreshScene() {
+  layerState.pipelines = { high: true, subHigh: true, medium: true, low: true }
+  layerState.pipePoints = { valve: true, regulator: true, flowmeter: true, terminal: true }
+  layerState.manholes = { cover: true, inspection: true }
+  searchKeyword.value = ''
+  selectedFeature.value = null
+  closeInfoWindow()
+  locateCenter()
+}
+
+function locateCenter() {
+  if (!mapInstance) return
+  mapInstance.centerAndZoom(new BMapGL.Point(centerPoint.lng, centerPoint.lat), MAP_DEFAULT_ZOOM)
+}
+
+function zoomIn() {
+  if (!mapInstance) return
+  const z = mapInstance.getZoom()
+  if (z < MAP_MAX_ZOOM) mapInstance.zoomIn()
+  else ElMessage.info('已放大至最大级别')
+}
+
+function zoomOut() {
+  if (!mapInstance) return
+  const z = mapInstance.getZoom()
+  if (z > MAP_MIN_ZOOM) mapInstance.zoomOut()
+  else ElMessage.info('已缩小至最小级别')
+}
+
+function toggleFullscreen() {
+  const target = screenRef.value
+  if (!target) return
+  if (!document.fullscreenElement) {
+    target.requestFullscreen?.()
+    return
+  }
+  document.exitFullscreen?.()
+}
+
+function handleFullscreenChange() {
+  if (!mapInstance) return
+  setTimeout(() => {
+    mapInstance.centerAndZoom(new BMapGL.Point(centerPoint.lng, centerPoint.lat), mapInstance.getZoom())
+  }, 200)
+}
+</script>
+
+<style scoped>
+.gis-dashboard {
+  height: calc(100vh - 84px);
+  padding: 16px;
+  background: #f2f5f9;
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+}
+
+.dashboard-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 10px 16px;
+  margin-bottom: 14px;
+  background: #fff;
+  border-radius: 10px;
+  box-shadow: 0 4px 16px rgba(15, 35, 95, 0.05);
+  flex-shrink: 0;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+
+.title-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #1f2d3d;
+  font-size: 17px;
+  font-weight: 700;
+}
+
+.title-metrics {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+
+.metric {
+  position: relative;
+  padding-left: 14px;
+  color: #44546a;
+  font-size: 13px;
+}
+
+.metric::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 50%;
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  transform: translateY(-50%);
+}
+
+.metric-danger::before { background: #e63946; }
+.metric-primary::before { background: #457b9d; }
+.metric-warning::before { background: #7f4f24; }
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.search-input {
+  width: 300px;
+}
+
+.dashboard-content {
+  flex: 1;
+  display: flex;
+  gap: 14px;
+  min-height: 0;
+}
+
+.dashboard-sidebar {
+  width: 320px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  flex-shrink: 0;
+  overflow-y: auto;
+}
+
+.panel-card {
+  border: none;
+  border-radius: 14px;
+  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
+  flex-shrink: 0;
+}
+
+.layer-group {
+  display: flex;
+  flex-direction: column;
+  gap: 9px;
+  padding-bottom: 16px;
+  margin-bottom: 16px;
+  border-bottom: 1px solid #eef2f8;
+}
+
+.layer-group-last {
+  padding-bottom: 0;
+  margin-bottom: 0;
+  border-bottom: none;
+}
+
+.layer-title {
+  color: #303133;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.legend-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: #4e5969;
+  font-size: 13px;
+}
+
+.legend-line {
+  width: 34px;
+  height: 0;
+  border-top-width: 3px;
+  border-top-style: solid;
+  border-radius: 999px;
+  flex-shrink: 0;
+}
+
+.legend-high { border-top-color: #e63946; }
+.legend-sub-high { border-top-color: #f77f00; }
+.legend-medium { border-top-color: #fcbf49; }
+.legend-low { border-top-color: #2a9d8f; }
+
+.legend-dot,
+.legend-square {
+  width: 12px;
+  height: 12px;
+  flex-shrink: 0;
+}
+
+.legend-dot { border-radius: 50%; }
+.legend-square { border-radius: 2px; }
+.legend-valve { background: #457b9d; }
+.legend-regulator { background: #e76f51; }
+.legend-flowmeter { background: #2d6a4f; }
+.legend-terminal { background: #6c757d; }
+.legend-cover { background: #7f4f24; }
+.legend-inspection { background: #b08968; }
+
+.stats-card {
+  background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
+}
+
+.stats-list {
+  display: flex;
+  flex-direction: column;
+  gap: 11px;
+}
+
+.stats-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #4e5969;
+  font-size: 13px;
+}
+
+.stats-row strong {
+  color: #1f2d3d;
+  font-size: 14px;
+}
+
+.detail-card {
+  flex: 1;
+  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;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border-radius: 14px;
+  overflow: hidden;
+  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
+  min-width: 0;
+}
+
+.map-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 10px 16px;
+  border-bottom: 1px solid #edf2f7;
+  flex-shrink: 0;
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.toolbar-right {
+  color: #7a8699;
+  font-size: 13px;
+}
+
+.gis-map {
+  flex: 1;
+  min-height: 0;
+  background: #e9f0e5;
+}
+
+.map-error {
+  position: absolute;
+  inset: 52px 0 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  background: rgba(255, 255, 255, 0.85);
+  font-size: 15px;
+  z-index: 10;
+}
+</style>
+
+<style>
+.pipe-label {
+  padding: 2px 8px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1.3;
+  white-space: nowrap;
+  text-shadow: 0 0 4px #fff, 0 0 4px #fff;
+  background: rgba(255, 255, 255, 0.7);
+  border-radius: 10px;
+}
+
+.marker-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.marker-text {
+  margin-top: 3px;
+  padding: 1px 7px;
+  color: #2f3a4f;
+  font-size: 11px;
+  line-height: 1.3;
+  white-space: nowrap;
+  background: rgba(255, 255, 255, 0.78);
+  border-radius: 10px;
+  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;
+}
+</style>

+ 200 - 0
src/views/subSystem/basic/Manhole.vue

@@ -0,0 +1,200 @@
+<template>
+  <div class="manhole-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="窨井名称">
+          <el-input v-model="searchForm.manholeName" placeholder="请输入名称" clearable />
+        </el-form-item>
+        <el-form-item label="窨井编码">
+          <el-input v-model="searchForm.manholeCode" placeholder="请输入编码" clearable />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in pipeNetworkStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增</el-button>
+          <el-button @click="viewMode = viewMode === 'table' ? 'map' : 'table'">
+            {{ viewMode === 'table' ? '地图模式' : '列表模式' }}
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card v-if="viewMode === 'table'" class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="manholeCode" label="窨井编码" min-width="120" />
+        <el-table-column prop="manholeName" label="窨井名称" min-width="150" />
+        <el-table-column prop="networkId" label="关联管网ID" min-width="120" />
+        <el-table-column prop="location" label="位置" min-width="180" />
+        <el-table-column prop="manholeDepth" label="深度(米)" width="90" />
+        <el-table-column prop="manholeShape" label="形状" width="80">
+          <template #default="{ row }">{{ getDictLabel(manholeShapeDict, row.manholeShape) }}</template>
+        </el-table-column>
+        <el-table-column prop="material" label="材质" width="100" />
+        <el-table-column prop="ownershipUnit" label="权属单位" min-width="150" />
+        <el-table-column prop="status" label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ getDictLabel(pipeNetworkStatusDict, row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-card v-else class="map-card">
+      <div id="manholeMap" style="width: 100%; height: 600px"></div>
+    </el-card>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="650px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-form-item label="窨井编码" prop="manholeCode">
+          <el-input v-model="formData.manholeCode" placeholder="请输入窨井编码" />
+        </el-form-item>
+        <el-form-item label="窨井名称" prop="manholeName">
+          <el-input v-model="formData.manholeName" placeholder="请输入窨井名称" />
+        </el-form-item>
+        <el-form-item label="关联管网">
+          <el-input v-model="formData.networkId" placeholder="请输入管网设施ID" />
+        </el-form-item>
+        <el-form-item label="位置描述">
+          <el-input v-model="formData.location" placeholder="请输入位置描述" />
+        </el-form-item>
+        <el-form-item label="经度">
+          <el-input-number v-model="formData.longitude" :precision="10" :step="0.0001" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="纬度">
+          <el-input-number v-model="formData.latitude" :precision="10" :step="0.0001" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="深度(米)">
+          <el-input-number v-model="formData.manholeDepth" :precision="2" :step="0.1" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="形状">
+          <el-select v-model="formData.manholeShape">
+            <el-option v-for="d in manholeShapeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="材质">
+          <el-input v-model="formData.material" placeholder="请输入材质" />
+        </el-form-item>
+        <el-form-item label="权属单位">
+          <el-input v-model="formData.ownershipUnit" placeholder="请输入权属单位" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="formData.status">
+            <el-option v-for="d in pipeNetworkStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, nextTick, watch } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getManholePage, addManhole, updateManhole, deleteManhole } from '@/api/pipeNetwork/basic'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const viewMode = ref('table')
+
+const searchForm = reactive({ manholeName: '', manholeCode: '', status: '' })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const formData = reactive({
+  manholeId: '', manholeCode: '', manholeName: '', networkId: '',
+  location: '', longitude: null, latitude: null, manholeDepth: null,
+  manholeShape: '', material: '', ownershipUnit: '', status: '0', remark: ''
+})
+
+const pipeNetworkStatusDict = ref([])
+const manholeShapeDict = ref([])
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const [statusRes, shapeRes] = await Promise.all([
+    getDicts('pipe_network_status'),
+    getDicts('manhole_shape')
+  ])
+  pipeNetworkStatusDict.value = statusRes.data
+  manholeShapeDict.value = shapeRes.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getManholePage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { manholeName: '', manholeCode: '', status: '' }); handleSearch() }
+function handleAdd() {
+  dialogTitle.value = '新增'
+  Object.assign(formData, { manholeId: '', manholeCode: '', manholeName: '', networkId: '', location: '', longitude: null, latitude: null, manholeDepth: null, manholeShape: '', material: '', ownershipUnit: '', status: '0', remark: '' })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSubmit() {
+  try {
+    if (formData.manholeId) { await updateManhole(formData); ElMessage.success('修改成功') }
+    else { await addManhole(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该窨井信息?', '提示', { type: 'warning' }).then(async () => {
+    await deleteManhole(row.manholeId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+
+async function initMap() {
+  await nextTick()
+  const el = document.getElementById('manholeMap')
+  if (!el) return
+  try {
+    const L = await import('leaflet')
+  } catch (e) {
+    el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">地图加载需要Leaflet依赖,请安装: npm install leaflet</div>'
+  }
+}
+watch(viewMode, (v) => { if (v === 'map') initMap() })
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.manhole-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 246 - 0
src/views/subSystem/basic/PipeNetwork.vue

@@ -0,0 +1,246 @@
+<template>
+  <div class="pipe-network-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm" class="search-form">
+        <el-form-item label="设施名称">
+          <el-input v-model="searchForm.networkName" placeholder="请输入设施名称" clearable />
+        </el-form-item>
+        <el-form-item label="管养单位">
+          <el-input v-model="searchForm.managementUnit" placeholder="请输入管养单位" clearable />
+        </el-form-item>
+        <el-form-item label="等级">
+          <el-select v-model="searchForm.networkLevel" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in pipeNetworkLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="设施类型">
+          <el-select v-model="searchForm.networkType" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in pipeNetworkTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="材质">
+          <el-input v-model="searchForm.material" placeholder="请输入材质" clearable style="width:160px" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in pipeNetworkStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增</el-button>
+          <el-button type="warning" @click="handleExport">导出</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
+        <el-table-column prop="networkCode" label="设施编码" min-width="120" />
+        <el-table-column prop="networkName" label="设施名称" min-width="150" />
+        <el-table-column prop="networkType" label="设施类型" width="100">
+          <template #default="{ row }">{{ getDictLabel(pipeNetworkTypeDict, row.networkType) }}</template>
+        </el-table-column>
+        <el-table-column prop="networkLevel" label="等级" width="90">
+          <template #default="{ row }">{{ getDictLabel(pipeNetworkLevelDict, row.networkLevel) }}</template>
+        </el-table-column>
+        <el-table-column prop="managementUnit" label="管养单位" min-width="150" />
+        <el-table-column prop="material" label="材质" width="100" />
+        <el-table-column prop="location" label="位置" min-width="180" />
+        <el-table-column prop="status" label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ getDictLabel(pipeNetworkStatusDict, row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum"
+        v-model:page-size="pageSize"
+        :total="total"
+        layout="total, prev, pager, next, sizes"
+        :page-sizes="[10, 20, 50]"
+        @change="loadData"
+        style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="650px" destroy-on-close>
+      <el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
+        <el-form-item label="设施编码" prop="networkCode">
+          <el-input v-model="formData.networkCode" placeholder="请输入设施编码" />
+        </el-form-item>
+        <el-form-item label="设施名称" prop="networkName">
+          <el-input v-model="formData.networkName" placeholder="请输入设施名称" />
+        </el-form-item>
+        <el-form-item label="设施类型">
+          <el-select v-model="formData.networkType">
+            <el-option v-for="d in pipeNetworkTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="等级">
+          <el-select v-model="formData.networkLevel">
+            <el-option v-for="d in pipeNetworkLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="管养单位">
+          <el-input v-model="formData.managementUnit" placeholder="请输入管养单位" />
+        </el-form-item>
+        <el-form-item label="位置描述">
+          <el-input v-model="formData.location" placeholder="请输入位置描述" />
+        </el-form-item>
+        <el-form-item label="材质">
+          <el-input v-model="formData.material" placeholder="请输入材质" />
+        </el-form-item>
+        <el-form-item label="经度">
+          <el-input-number v-model="formData.longitude" :precision="10" :step="0.0001" placeholder="请输入经度" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="纬度">
+          <el-input-number v-model="formData.latitude" :precision="10" :step="0.0001" placeholder="请输入纬度" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="formData.status" placeholder="请选择">
+            <el-option v-for="d in pipeNetworkStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getPipeNetworkPage, getPipeNetworkById, addPipeNetwork, updatePipeNetwork, deletePipeNetwork } from '@/api/pipeNetwork/basic'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+
+const searchForm = reactive({
+  networkName: '', managementUnit: '', networkLevel: '', networkType: '', material: '', status: ''
+})
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const formData = reactive({
+  networkId: '', networkCode: '', networkName: '', networkType: '0',
+  networkLevel: '', managementUnit: '', location: '', material: '',
+  longitude: null, latitude: null, status: '0', remark: ''
+})
+
+const rules = {
+  networkCode: [{ required: true, message: '请输入设施编码', trigger: 'blur' }],
+  networkName: [{ required: true, message: '请输入设施名称', trigger: 'blur' }]
+}
+
+const pipeNetworkStatusDict = ref([])
+const pipeNetworkLevelDict = ref([])
+const pipeNetworkTypeDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const [statusRes, levelRes, typeRes] = await Promise.all([
+    getDicts('pipe_network_status'),
+    getDicts('pipe_network_level'),
+    getDicts('pipe_network_type')
+  ])
+  pipeNetworkStatusDict.value = statusRes.data
+  pipeNetworkLevelDict.value = levelRes.data
+  pipeNetworkTypeDict.value = typeRes.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getPipeNetworkPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) {
+    console.error(e)
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pageNum.value = 1
+  loadData()
+}
+
+function handleReset() {
+  Object.assign(searchForm, { networkName: '', managementUnit: '', networkLevel: '', networkType: '', material: '', status: '' })
+  handleSearch()
+}
+
+function handleAdd() {
+  dialogTitle.value = '新增'
+  Object.assign(formData, { networkId: '', networkCode: '', networkName: '', networkType: '0', networkLevel: '', managementUnit: '', location: '', material: '', longitude: null, latitude: null, status: '0', remark: '' })
+  dialogVisible.value = true
+}
+
+function handleEdit(row) {
+  dialogTitle.value = '编辑'
+  Object.assign(formData, row)
+  dialogVisible.value = true
+}
+
+async function handleSubmit() {
+  const valid = await formRef.value.validate().catch(() => false)
+  if (!valid) return
+  try {
+    if (formData.networkId) {
+      await updatePipeNetwork(formData)
+      ElMessage.success('修改成功')
+    } else {
+      await addPipeNetwork(formData)
+      ElMessage.success('新增成功')
+    }
+    dialogVisible.value = false
+    loadData()
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该管网信息?', '提示', { type: 'warning' }).then(async () => {
+    await deletePipeNetwork(row.networkId)
+    ElMessage.success('删除成功')
+    loadData()
+  }).catch(() => {})
+}
+
+function handleExport() {
+  ElMessage.info('导出功能开发中')
+}
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.pipe-network-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+.search-form { display: flex; flex-wrap: wrap; }
+</style>

+ 164 - 0
src/views/subSystem/basic/PointManage.vue

@@ -0,0 +1,164 @@
+<template>
+  <div class="point-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="测点名称">
+          <el-input v-model="searchForm.pointName" placeholder="请输入测点名称" clearable />
+        </el-form-item>
+        <el-form-item label="测点类型">
+          <el-select v-model="searchForm.pointType" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in pointTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增测点</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="pointCode" label="测点编码" width="120" />
+        <el-table-column prop="pointName" label="测点名称" min-width="150" />
+        <el-table-column prop="pointType" label="测点类型" width="110">
+          <template #default="{ row }">{{ getDictLabel(pointTypeDict, row.pointType) }}</template>
+        </el-table-column>
+        <el-table-column prop="networkId" label="所属管网" width="130" />
+        <el-table-column prop="location" label="位置" min-width="180" />
+        <el-table-column prop="status" label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ getDictLabel(pointStatusDict, row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="info" size="small" @click="handleRelDevice(row)">关联设备</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="550px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-form-item label="测点编码" prop="pointCode">
+          <el-input v-model="formData.pointCode" placeholder="请输入测点编码" />
+        </el-form-item>
+        <el-form-item label="测点名称" prop="pointName">
+          <el-input v-model="formData.pointName" placeholder="请输入测点名称" />
+        </el-form-item>
+        <el-form-item label="测点类型">
+          <el-select v-model="formData.pointType" style="width: 100%">
+            <el-option v-for="d in pointTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="所属管网">
+          <el-input v-model="formData.networkId" placeholder="请输入管网ID" />
+        </el-form-item>
+        <el-form-item label="位置描述">
+          <el-input v-model="formData.location" />
+        </el-form-item>
+        <el-form-item label="经度">
+          <el-input-number v-model="formData.longitude" :precision="10" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="纬度">
+          <el-input-number v-model="formData.latitude" :precision="10" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="formData.status">
+            <el-option v-for="d in pointStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getPointPage, addPoint, updatePoint, deletePoint } from '@/api/pipeNetwork/basic'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ pointName: '', pointType: '' })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const formData = reactive({
+  pointId: '', pointCode: '', pointName: '', pointType: '',
+  networkId: '', location: '', longitude: null, latitude: null, status: 'ACTIVE'
+})
+
+const pointTypeDict = ref([])
+const pointStatusDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === String(value))
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const [typeRes, statusRes] = await Promise.all([
+    getDicts('point_type'),
+    getDicts('point_status')
+  ])
+  pointTypeDict.value = typeRes.data
+  pointStatusDict.value = statusRes.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getPointPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { pointName: '', pointType: '' }); handleSearch() }
+function handleAdd() {
+  dialogTitle.value = '新增测点'
+  Object.assign(formData, { pointId: '', pointCode: '', pointName: '', pointType: '', networkId: '', location: '', longitude: null, latitude: null, status: 'ACTIVE' })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSave() {
+  try {
+    if (formData.pointId) { await updatePoint(formData); ElMessage.success('修改成功') }
+    else { await addPoint(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该测点?', '提示', { type: 'warning' }).then(async () => {
+    await deletePoint(row.pointId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+function handleRelDevice(row) {
+  ElMessage.info('设备关联功能待实现')
+}
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.point-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 149 - 0
src/views/subSystem/basic/RepairRecord.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="repair-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="管网ID">
+          <el-input v-model="searchForm.networkId" placeholder="请输入管网ID" clearable />
+        </el-form-item>
+        <el-form-item label="维修类型">
+          <el-select v-model="searchForm.repairType" placeholder="请选择" clearable style="width:160px">
+            <el-option v-for="d in repairTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue"  />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="networkId" label="管网ID" min-width="130" />
+        <el-table-column prop="repairDate" label="维修日期" width="110" />
+        <el-table-column prop="repairType" label="维修类型" width="120">
+          <template #default="{ row }">{{ getDictLabel(repairTypeDict, row.repairType) }}</template>
+        </el-table-column>
+        <el-table-column prop="repairContent" label="维修内容" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="repairCompany" label="维修单位" min-width="150" />
+        <el-table-column prop="repairCost" label="费用(元)" width="110" />
+        <el-table-column prop="repairPerson" label="负责人" width="100" />
+        <el-table-column prop="createTime" label="创建时间" width="160" />
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-form-item label="管网ID" prop="networkId">
+          <el-input v-model="formData.networkId" placeholder="请输入管网设施ID" />
+        </el-form-item>
+        <el-form-item label="维修日期" prop="repairDate">
+          <el-date-picker v-model="formData.repairDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="维修类型" prop="repairType">
+          <el-select v-model="formData.repairType" style="width: 100%">
+            <el-option v-for="d in repairTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="维修内容">
+          <el-input v-model="formData.repairContent" type="textarea" :rows="3" />
+        </el-form-item>
+        <el-form-item label="维修单位">
+          <el-input v-model="formData.repairCompany" />
+        </el-form-item>
+        <el-form-item label="费用(元)">
+          <el-input-number v-model="formData.repairCost" :precision="2" :step="100" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="负责人">
+          <el-input v-model="formData.repairPerson" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getRepairRecordPage, addRepairRecord, updateRepairRecord, deleteRepairRecord } from '@/api/pipeNetwork/basic'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ networkId: '', repairType: '' })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const formData = reactive({
+  repairId: '', networkId: '', repairDate: '', repairType: '',
+  repairContent: '', repairCompany: '', repairCost: null, repairPerson: '', remark: ''
+})
+
+const repairTypeDict = ref([])
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === String(value))
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const res = await getDicts('pipe_repair_type')
+  repairTypeDict.value = res.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getRepairRecordPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { networkId: '', repairType: '' }); handleSearch() }
+function handleAdd() {
+  dialogTitle.value = '新增'
+  Object.assign(formData, { repairId: '', networkId: '', repairDate: '', repairType: '', repairContent: '', repairCompany: '', repairCost: null, repairPerson: '', remark: '' })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSubmit() {
+  try {
+    if (formData.repairId) { await updateRepairRecord(formData); ElMessage.success('修改成功') }
+    else { await addRepairRecord(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该维修记录?', '提示', { type: 'warning' }).then(async () => {
+    await deleteRepairRecord(row.repairId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.repair-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 248 - 0
src/views/subSystem/hidden/HazardList.vue

@@ -0,0 +1,248 @@
+<template>
+  <div class="hazard-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="隐患类型">
+          <el-select v-model="searchForm.hazardType" placeholder="请选择" clearable>
+            <el-option v-for="d in hazardTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="隐患等级">
+          <el-select v-model="searchForm.hazardLevel" placeholder="请选择" clearable>
+            <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.hazardStatus" placeholder="请选择" clearable>
+            <el-option v-for="d in hazardStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="报送状态">
+          <el-select v-model="searchForm.isSubmitted" placeholder="请选择" clearable>
+            <el-option label="未报送" :value="0" />
+            <el-option label="已报送" :value="1" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="hazardNo" label="隐患编号" width="120" />
+        <el-table-column prop="hazardType" label="隐患类型" width="100">
+          <template #default="{ row }">{{ getDictLabel(hazardTypeDict, row.hazardType) }}</template>
+        </el-table-column>
+        <el-table-column prop="hazardLevel" label="隐患等级" width="90">
+          <template #default="{ row }">
+            <el-tag :type="levelTag(row.hazardLevel)">{{ getDictLabel(hazardLevelDict, row.hazardLevel) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="hazardDesc" label="隐患描述" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="hazardSource" label="隐患来源" width="100">
+          <template #default="{ row }">{{ getDictLabel(hazardSourceDict, row.hazardSource) }}</template>
+        </el-table-column>
+        <el-table-column prop="hazardStatus" label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="statusTag(row.hazardStatus)">{{ getDictLabel(hazardStatusDict, row.hazardStatus) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="reportUnit" label="上报单位" width="120" />
+        <el-table-column prop="reportTime" label="上报时间" width="160" />
+        <el-table-column label="操作" width="260" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="warning" size="small" @click="handleSubmitHazard(row)" :disabled="row.isSubmitted === 1">
+              {{ row.isSubmitted === 1 ? '已报送' : '报送' }}
+            </el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="750px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="隐患编号" prop="hazardNo">
+              <el-input v-model="formData.hazardNo" placeholder="请输入隐患编号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="隐患类型" prop="hazardType">
+              <el-select v-model="formData.hazardType" style="width: 100%">
+                <el-option v-for="d in hazardTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="隐患等级" prop="hazardLevel">
+              <el-select v-model="formData.hazardLevel" style="width: 100%">
+                <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="隐患来源">
+              <el-select v-model="formData.hazardSource" style="width: 100%">
+                <el-option v-for="d in hazardSourceDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="上报单位">
+              <el-input v-model="formData.reportUnit" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="上报时间">
+              <el-date-picker v-model="formData.reportTime" type="datetime" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="隐患描述">
+              <el-input v-model="formData.hazardDesc" type="textarea" :rows="3" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="整改方案">
+              <el-input v-model="formData.rectifyPlan" type="textarea" :rows="3" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="整改期限">
+              <el-date-picker v-model="formData.rectifyDeadline" type="date" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="整改责任人">
+              <el-input v-model="formData.rectifyPerson" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="经度">
+              <el-input-number v-model="formData.longitude" :precision="10" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="纬度">
+              <el-input-number v-model="formData.latitude" :precision="10" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getHazardPage, addHazard, updateHazard, deleteHazard, submitHazard } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const formData = reactive({
+  hazardId: '', hazardNo: '', hazardType: '', hazardLevel: '', hazardDesc: '',
+  hazardSource: '', hazardStatus: '2', reportUnit: '', reportTime: '',
+  rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '', longitude: null, latitude: null
+})
+
+const hazardTypeDict = ref([])
+const hazardLevelDict = ref([])
+const hazardStatusDict = ref([])
+const hazardSourceDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+function levelTag(value) {
+  const map = { '0': 'danger', '1': 'warning', '2': 'info', '3': '' }
+  return map[value] || 'info'
+}
+function statusTag(value) {
+  const map = { '0': 'info', '1': 'success', '2': 'warning', '3': '' }
+  return map[value] || 'info'
+}
+
+async function loadDicts() {
+  const [typeRes, levelRes, statusRes, sourceRes] = await Promise.all([
+    getDicts('hazard_type'),
+    getDicts('hazard_level'),
+    getDicts('hazard_status'),
+    getDicts('hazard_source')
+  ])
+  hazardTypeDict.value = typeRes.data
+  hazardLevelDict.value = levelRes.data
+  hazardStatusDict.value = statusRes.data
+  hazardSourceDict.value = sourceRes.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getHazardPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() {
+  Object.assign(searchForm, { hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null })
+  handleSearch()
+}
+function handleAdd() {
+  dialogTitle.value = '新增'
+  Object.assign(formData, { hazardId: '', hazardNo: '', hazardType: '', hazardLevel: '', hazardDesc: '', hazardSource: '', hazardStatus: '2', reportUnit: '', reportTime: '', rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '', longitude: null, latitude: null })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSave() {
+  try {
+    if (formData.hazardId) { await updateHazard(formData); ElMessage.success('修改成功') }
+    else { await addHazard(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该隐患记录?', '提示', { type: 'warning' }).then(async () => {
+    await deleteHazard(row.hazardId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+async function handleSubmitHazard(row) {
+  ElMessageBox.confirm('确认报送该隐患?', '提示', { type: 'warning' }).then(async () => {
+    await submitHazard(row.hazardId); ElMessage.success('报送成功'); loadData()
+  }).catch(() => {})
+}
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.hazard-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 109 - 0
src/views/subSystem/hidden/HazardMap.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="hazard-map-container">
+    <el-card class="legend-card">
+      <template #header><span>图例说明</span></template>
+      <div class="legend-items">
+        <div class="legend-item" v-for="item in levelLegend" :key="item.value">
+          <span class="dot" :style="{ background: item.color }"></span> {{ item.label }}隐患
+        </div>
+      </div>
+    </el-card>
+    <div id="hazardMap" class="map-container"></div>
+    <el-drawer v-model="detailVisible" title="隐患详情" size="400px">
+      <template v-if="currentHazard">
+        <el-descriptions :column="1" border size="small">
+          <el-descriptions-item label="隐患编号">{{ currentHazard.hazardNo }}</el-descriptions-item>
+          <el-descriptions-item label="隐患类型">{{ getDictLabel(hazardTypeDict, currentHazard.hazardType) }}</el-descriptions-item>
+          <el-descriptions-item label="隐患等级">
+            <el-tag :type="levelTag(currentHazard.hazardLevel)">{{ getDictLabel(hazardLevelDict, currentHazard.hazardLevel) }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="状态">{{ getDictLabel(hazardStatusDict, currentHazard.hazardStatus) }}</el-descriptions-item>
+          <el-descriptions-item label="隐患描述">{{ currentHazard.hazardDesc }}</el-descriptions-item>
+          <el-descriptions-item label="整改反馈">{{ currentHazard.rectifyFeedback || '暂无' }}</el-descriptions-item>
+          <el-descriptions-item label="上报时间">{{ currentHazard.reportTime }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, nextTick } from 'vue'
+import { getHazardMapPoints } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const detailVisible = ref(false)
+const currentHazard = ref(null)
+let mapInstance = null
+
+const hazardTypeDict = ref([])
+const hazardLevelDict = ref([])
+const hazardStatusDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+const levelColors = { '0': '#f5222d', '1': '#fa8c16', '2': '#1890ff', '3': '#52c41a' }
+function levelTag(level) {
+  const map = { '0': 'danger', '1': 'warning', '2': '', '3': 'success' }
+  return map[level] || ''
+}
+
+const levelLegend = computed(() =>
+  hazardLevelDict.value.map(d => ({ value: d.dictValue, label: d.dictLabel, color: levelColors[d.dictValue] || '#999' }))
+)
+
+async function loadDicts() {
+  const [typeRes, levelRes, statusRes] = await Promise.all([
+    getDicts('hazard_type'),
+    getDicts('hazard_level'),
+    getDicts('hazard_status')
+  ])
+  hazardTypeDict.value = typeRes.data
+  hazardLevelDict.value = levelRes.data
+  hazardStatusDict.value = statusRes.data
+}
+
+async function initMap() {
+  await nextTick()
+  const el = document.getElementById('hazardMap')
+  if (!el) return
+  try {
+    const L = await import('leaflet')
+    mapInstance = L.map('hazardMap').setView([34.77, 113.65], 12)
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; OpenStreetMap contributors'
+    }).addTo(mapInstance)
+
+    const res = await getHazardMapPoints()
+    const points = res.data || []
+    for (const p of points) {
+      if (!p.longitude || !p.latitude) continue
+      const color = levelColors[p.hazardLevel] || '#999'
+      const marker = L.circleMarker([p.latitude, p.longitude], {
+        radius: 10, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.8
+      }).addTo(mapInstance)
+      const typeLabel = getDictLabel(hazardTypeDict, p.hazardType)
+      const levelLabel = getDictLabel(hazardLevelDict, p.hazardLevel)
+      const statusLabel = getDictLabel(hazardStatusDict, p.hazardStatus)
+      marker.bindPopup(`<b>${p.hazardNo}</b><br/>${typeLabel} | ${levelLabel}<br/>${statusLabel}`)
+      marker.on('click', () => { currentHazard.value = p; detailVisible.value = true })
+    }
+  } catch (e) {
+    el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">请安装Leaflet: npm install leaflet</div>'
+  }
+}
+
+onMounted(async () => { await loadDicts(); initMap() })
+</script>
+
+<style scoped>
+.hazard-map-container { position: relative; height: calc(100vh - 100px); padding: 16px; }
+.legend-card { position: absolute; top: 32px; right: 32px; z-index: 1000; width: 160px; }
+.legend-items { display: flex; flex-direction: column; gap: 6px; }
+.legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
+.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
+.map-container { width: 100%; height: 100%; border-radius: 4px; }
+</style>

+ 95 - 0
src/views/subSystem/hidden/HazardStatistics.vue

@@ -0,0 +1,95 @@
+<template>
+  <div class="statistics-container">
+    <el-row :gutter="16">
+      <el-col :span="12">
+        <el-card class="chart-card">
+          <template #header><span>按隐患类型统计</span></template>
+          <div ref="chartTypeRef" class="chart"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card class="chart-card">
+          <template #header><span>按整改状态统计</span></template>
+          <div ref="chartStatusRef" class="chart"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, nextTick } from 'vue'
+import { getHazardStatisticsByType, getHazardStatisticsByStatus } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const chartTypeRef = ref(null)
+const chartStatusRef = ref(null)
+
+const hazardTypeDict = ref([])
+const hazardStatusDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+async function loadDicts() {
+  const [typeRes, statusRes] = await Promise.all([
+    getDicts('hazard_type'),
+    getDicts('hazard_status')
+  ])
+  hazardTypeDict.value = typeRes.data
+  hazardStatusDict.value = statusRes.data
+}
+
+async function initCharts() {
+  await nextTick()
+  try {
+    const echarts = (await import('echarts')).default
+    const [typeRes, statusRes] = await Promise.all([
+      getHazardStatisticsByType(), getHazardStatisticsByStatus()
+    ])
+
+    // 按类型统计饼图
+    const typeData = typeRes.data || []
+    if (chartTypeRef.value) {
+      const chart1 = echarts.init(chartTypeRef.value)
+      chart1.setOption({
+        tooltip: { trigger: 'item' },
+        series: [{
+          type: 'pie', radius: ['40%', '70%'],
+          data: typeData.map(d => ({ name: getDictLabel(hazardTypeDict, d.name), value: d.value })),
+          label: { formatter: '{b}: {c}' }
+        }]
+      })
+      window.addEventListener('resize', () => chart1.resize())
+    }
+
+    // 按状态统计柱状图
+    const statusData = statusRes.data || []
+    if (chartStatusRef.value) {
+      const chart2 = echarts.init(chartStatusRef.value)
+      chart2.setOption({
+        tooltip: { trigger: 'axis' },
+        xAxis: { type: 'category', data: statusData.map(d => getDictLabel(hazardStatusDict, d.name)) },
+        yAxis: { type: 'value' },
+        series: [{
+          type: 'bar', data: statusData.map(d => d.value),
+          itemStyle: { color: '#1890ff', borderRadius: [4, 4, 0, 0] }
+        }]
+      })
+      window.addEventListener('resize', () => chart2.resize())
+    }
+  } catch (e) {
+    console.error('图表加载失败', e)
+  }
+}
+
+onMounted(async () => { await loadDicts(); initCharts() })
+</script>
+
+<style scoped>
+.statistics-container { padding: 16px; }
+.chart-card { margin-bottom: 16px; }
+.chart { width: 100%; height: 400px; }
+</style>

+ 151 - 0
src/views/subSystem/monitor/Dashboard.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="dashboard-container">
+    <el-row :gutter="16" class="stat-row">
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-value" style="color:#1890ff">{{ pressureValue }}</div>
+          <div class="stat-label">实时压力 (MPa)</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-value" style="color:#52c41a">{{ flowValue }}</div>
+          <div class="stat-label">实时流量 (m³/h)</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-value" style="color:#faad14">{{ gasValue }}</div>
+          <div class="stat-label">气体浓度 (%)</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-value" style="color:#f5222d">{{ alarmCount }}</div>
+          <div class="stat-label">今日报警次数</div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16" style="margin-top: 16px">
+      <el-col :span="12">
+        <el-card>
+          <template #header><span>压力历史曲线</span></template>
+          <div ref="pressureChart" class="chart"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card>
+          <template #header><span>流量历史曲线</span></template>
+          <div ref="flowChart" class="chart"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16" style="margin-top: 16px">
+      <el-col :span="12">
+        <el-card>
+          <template #header><span>视频监控列表</span></template>
+          <el-table :data="videoList" border size="small">
+            <el-table-column prop="deviceName" label="摄像头名称" />
+            <el-table-column prop="deviceCode" label="设备编码" />
+            <el-table-column prop="location" label="安装位置" />
+            <el-table-column prop="status" label="状态" width="80">
+              <template #default="{ row }">
+                <el-tag :type="row.status === 'ONLINE' ? 'success' : 'danger'" size="small">{{ row.status === 'ONLINE' ? '在线' : '离线' }}</el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card>
+          <template #header><span>最新气体浓度报警</span></template>
+          <el-table :data="gasAlarms" border size="small" empty-text="暂无报警数据">
+            <el-table-column prop="deviceCode" label="设备编码" />
+            <el-table-column prop="actualValue" label="实际值" width="90" />
+            <el-table-column prop="alarmTime" label="报警时间" width="160" />
+          </el-table>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
+import { getPressureHistory, getFlowHistory, getGasAlarmLatest, getVideoList } from '@/api/pipeNetwork/hazard'
+
+const pressureValue = ref('0.35')
+const flowValue = ref('78.5')
+const gasValue = ref('0.12')
+const alarmCount = ref(3)
+const videoList = ref([])
+const gasAlarms = ref([])
+const pressureChart = ref(null)
+const flowChart = ref(null)
+let timer = null
+
+async function loadData() {
+  try {
+    const [pRes, fRes, gRes, vRes] = await Promise.all([
+      getPressureHistory(null, 24), getFlowHistory(null, 24), getGasAlarmLatest(), getVideoList()
+    ])
+    videoList.value = vRes.data || []
+    gasAlarms.value = gRes.data || []
+    renderPressureChart(pRes.data || [])
+    renderFlowChart(fRes.data || [])
+  } catch (e) { console.error(e) }
+}
+
+async function renderPressureChart(data) {
+  await nextTick()
+  if (!pressureChart.value) return
+  try {
+    const echarts = (await import('echarts')).default
+    const chart = echarts.init(pressureChart.value)
+    chart.setOption({
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'time', name: '时间' },
+      yAxis: { type: 'value', name: 'MPa' },
+      series: [{ type: 'line', smooth: true, data: data.map(d => [d.time, d.value]), areaStyle: {} }]
+    })
+    window.addEventListener('resize', () => chart.resize())
+  } catch (e) { console.error(e) }
+}
+
+async function renderFlowChart(data) {
+  await nextTick()
+  if (!flowChart.value) return
+  try {
+    const echarts = (await import('echarts')).default
+    const chart = echarts.init(flowChart.value)
+    chart.setOption({
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'time', name: '时间' },
+      yAxis: { type: 'value', name: 'm³/h' },
+      series: [{ type: 'line', smooth: true, data: data.map(d => [d.time, d.value]), areaStyle: {} }]
+    })
+    window.addEventListener('resize', () => chart.resize())
+  } catch (e) { console.error(e) }
+}
+
+onMounted(() => {
+  loadData()
+  timer = setInterval(() => {
+    pressureValue.value = (0.3 + Math.random() * 0.2).toFixed(2)
+    flowValue.value = (60 + Math.random() * 50).toFixed(1)
+    gasValue.value = (Math.random() * 0.2).toFixed(2)
+  }, 5000)
+})
+
+onUnmounted(() => { clearInterval(timer) })
+</script>
+
+<style scoped>
+.dashboard-container { padding: 16px; }
+.stat-card { text-align: center; }
+.stat-value { font-size: 32px; font-weight: bold; }
+.stat-label { font-size: 14px; color: #999; margin-top: 8px; }
+.chart { width: 100%; height: 320px; }
+</style>

+ 40 - 0
src/views/subSystem/monitor/MonitorMap.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="monitor-map-container">
+    <div id="monitorMap" class="map"></div>
+    <el-drawer v-model="chartVisible" title="实时数据曲线" size="500px">
+      <div ref="detailChart" class="detail-chart"></div>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, nextTick } from 'vue'
+import { getPointsWithLatestData } from '@/api/pipeNetwork/hazard'
+
+const chartVisible = ref(false)
+const detailChart = ref(null)
+let mapInstance = null
+
+async function initMap() {
+  await nextTick()
+  const el = document.getElementById('monitorMap')
+  if (!el) return
+  try {
+    const L = await import('leaflet')
+    mapInstance = L.map('monitorMap').setView([34.77, 113.65], 13)
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; OpenStreetMap contributors'
+    }).addTo(mapInstance)
+  } catch (e) {
+    el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">请安装Leaflet: npm install leaflet</div>'
+  }
+}
+
+onMounted(() => { initMap() })
+</script>
+
+<style scoped>
+.monitor-map-container { height: calc(100vh - 100px); padding: 16px; }
+.map { width: 100%; height: 100%; border-radius: 4px; }
+.detail-chart { width: 100%; height: 400px; }
+</style>

+ 205 - 0
src/views/subSystem/risk/RiskList.vue

@@ -0,0 +1,205 @@
+<template>
+  <div class="risk-container">
+    <el-card class="search-card">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item label="管网ID">
+          <el-input v-model="searchForm.networkId" placeholder="请输入管网ID" clearable />
+        </el-form-item>
+        <el-form-item label="风险等级">
+          <el-select v-model="searchForm.riskLevel" placeholder="请选择" clearable>
+            <el-option v-for="d in riskLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleAdd">新增评估</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card">
+      <el-table :data="tableData" border stripe v-loading="loading">
+        <el-table-column prop="networkId" label="管网ID" min-width="130" />
+        <el-table-column prop="riskLevel" label="风险等级" width="100">
+          <template #default="{ row }">
+            <el-tag :color="getRiskColorValue(row.riskColor)" effect="dark" size="small">{{ getDictLabel(riskLevelDict, row.riskLevel) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="riskScore" label="风险评分" width="100" />
+        <el-table-column prop="riskColor" label="风险颜色" width="90">
+          <template #default="{ row }">
+            <span :style="{ display: 'inline-block', width: 16, height: 16, background: getRiskColorValue(row.riskColor), borderRadius: 4, verticalAlign: 'middle' }"></span>
+            <span style="margin-left: 6px">{{ getDictLabel(riskColorDict, row.riskColor) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="assessmentModel" label="评估模型" width="120" />
+        <el-table-column prop="assessmentTime" label="评估时间" width="160" />
+        <el-table-column prop="assessmentPerson" label="评估人" width="100" />
+        <el-table-column label="操作" width="200" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="handleDetail(row)">详情</el-button>
+            <el-button type="success" size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
+        layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
+        @change="loadData" style="margin-top: 16px; justify-content: flex-end"
+      />
+    </el-card>
+
+    <el-dialog title="风险评估详情" v-model="detailVisible" width="700px">
+      <el-descriptions :column="2" border v-if="detailData">
+        <el-descriptions-item label="风险ID">{{ detailData.riskId }}</el-descriptions-item>
+        <el-descriptions-item label="管网ID">{{ detailData.networkId }}</el-descriptions-item>
+        <el-descriptions-item label="风险等级">{{ getDictLabel(riskLevelDict, detailData.riskLevel) }}</el-descriptions-item>
+        <el-descriptions-item label="风险评分">{{ detailData.riskScore }}</el-descriptions-item>
+        <el-descriptions-item label="评估模型">{{ detailData.assessmentModel }}</el-descriptions-item>
+        <el-descriptions-item label="评估人">{{ detailData.assessmentPerson }}</el-descriptions-item>
+        <el-descriptions-item label="风险构成要素" :span="2">{{ detailData.riskFactors }}</el-descriptions-item>
+        <el-descriptions-item label="防护目标" :span="2">{{ detailData.protectiveTargets }}</el-descriptions-item>
+        <el-descriptions-item label="危险源" :span="2">{{ detailData.hazardSources }}</el-descriptions-item>
+        <el-descriptions-item label="维修历史" :span="2">{{ detailData.repairHistorySummary }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ detailData.remark }}</el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="650px" destroy-on-close>
+      <el-form :model="formData" ref="formRef" label-width="100px">
+        <el-form-item label="管网ID" prop="networkId">
+          <el-input v-model="formData.networkId" placeholder="请输入管网ID" />
+        </el-form-item>
+        <el-form-item label="风险等级" prop="riskLevel">
+          <el-select v-model="formData.riskLevel" style="width: 100%">
+            <el-option v-for="d in riskLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="风险评分">
+          <el-input-number v-model="formData.riskScore" :precision="2" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="风险颜色">
+          <el-select v-model="formData.riskColor" style="width: 100%">
+            <el-option v-for="d in riskColorDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="评估模型">
+          <el-input v-model="formData.assessmentModel" />
+        </el-form-item>
+        <el-form-item label="评估人">
+          <el-input v-model="formData.assessmentPerson" />
+        </el-form-item>
+        <el-form-item label="评估时间">
+          <el-date-picker v-model="formData.assessmentTime" type="datetime" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="风险要素">
+          <el-input v-model="formData.riskFactors" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="防护目标">
+          <el-input v-model="formData.protectiveTargets" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="危险源">
+          <el-input v-model="formData.hazardSources" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getRiskPage, getRiskById, addRisk, updateRisk, deleteRisk } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const loading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ networkId: '', riskLevel: '' })
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formRef = ref(null)
+const detailVisible = ref(false)
+const detailData = ref(null)
+const formData = reactive({
+  riskId: '', networkId: '', riskLevel: '', riskScore: null, riskColor: '',
+  assessmentModel: '', assessmentTime: '', assessmentPerson: '',
+  riskFactors: '', protectiveTargets: '', hazardSources: '', repairHistorySummary: '', remark: ''
+})
+
+const riskLevelDict = ref([])
+const riskColorDict = ref([])
+
+const riskColorValueMap = { '0': '#F56C6C', '1': '#E6A23C', '2': '#F4D03F', '3': '#409EFF' }
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+function getRiskColorValue(value) {
+  return riskColorValueMap[value] || '#909399'
+}
+
+async function loadDicts() {
+  const [levelRes, colorRes] = await Promise.all([
+    getDicts('risk_level'),
+    getDicts('risk_color')
+  ])
+  riskLevelDict.value = levelRes.data
+  riskColorDict.value = colorRes.data
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getRiskPage(pageNum.value, pageSize.value, searchForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() { Object.assign(searchForm, { networkId: '', riskLevel: '' }); handleSearch() }
+function handleAdd() {
+  dialogTitle.value = '新增评估'
+  Object.assign(formData, { riskId: '', networkId: '', riskLevel: '', riskScore: null, riskColor: '', assessmentModel: '', assessmentTime: '', assessmentPerson: '', riskFactors: '', protectiveTargets: '', hazardSources: '', repairHistorySummary: '', remark: '' })
+  dialogVisible.value = true
+}
+function handleEdit(row) { dialogTitle.value = '编辑'; Object.assign(formData, row); dialogVisible.value = true }
+async function handleSave() {
+  try {
+    if (formData.riskId) { await updateRisk(formData); ElMessage.success('修改成功') }
+    else { await addRisk(formData); ElMessage.success('新增成功') }
+    dialogVisible.value = false; loadData()
+  } catch (e) { console.error(e) }
+}
+function handleDelete(row) {
+  ElMessageBox.confirm('确认删除该评估记录?', '提示', { type: 'warning' }).then(async () => {
+    await deleteRisk(row.riskId); ElMessage.success('删除成功'); loadData()
+  }).catch(() => {})
+}
+async function handleDetail(row) {
+  try {
+    const res = await getRiskById(row.riskId)
+    detailData.value = res.data
+    detailVisible.value = true
+  } catch (e) { console.error(e) }
+}
+
+onMounted(async () => { await loadDicts(); loadData() })
+</script>
+
+<style scoped>
+.risk-container { padding: 16px; }
+.search-card { margin-bottom: 16px; }
+</style>

+ 101 - 0
src/views/subSystem/risk/RiskMap.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="risk-map-container">
+    <el-card class="legend-card">
+      <template #header><span>风险四色图例</span></template>
+      <div class="legend-items">
+        <div class="legend-item" v-for="item in colorLegend" :key="item.value">
+          <span class="line" :style="{ background: item.color }"></span> {{ item.riskLabel }} - {{ item.colorLabel }}
+        </div>
+      </div>
+    </el-card>
+    <div id="riskMap" class="map"></div>
+    <el-drawer v-model="detailVisible" title="管线风险评估" size="400px">
+      <template v-if="currentItem">
+        <el-descriptions :column="1" border size="small">
+          <el-descriptions-item label="管网名称">{{ currentItem.networkName }}</el-descriptions-item>
+          <el-descriptions-item label="管网编码">{{ currentItem.networkCode }}</el-descriptions-item>
+          <el-descriptions-item label="风险等级">{{ getDictLabel(riskLevelDict, currentItem.riskLevel) }}</el-descriptions-item>
+          <el-descriptions-item label="风险评分">{{ currentItem.riskScore }}</el-descriptions-item>
+          <el-descriptions-item label="风险颜色">{{ getDictLabel(riskColorDict, currentItem.riskColor) }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, nextTick } from 'vue'
+import { getFourColorMap } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+
+const detailVisible = ref(false)
+const currentItem = ref(null)
+let mapInstance = null
+
+const riskLevelDict = ref([])
+const riskColorDict = ref([])
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+const colorMap = { '0': '#f5222d', '1': '#fa8c16', '2': '#fadb14', '3': '#1890ff' }
+
+const colorLegend = computed(() =>
+  riskColorDict.value.map(d => ({
+    value: d.dictValue,
+    colorLabel: d.dictLabel,
+    color: colorMap[d.dictValue] || '#999',
+    riskLabel: getDictLabel(riskLevelDict, d.dictValue)
+  }))
+)
+
+async function loadDicts() {
+  const [levelRes, colorRes] = await Promise.all([
+    getDicts('risk_level'),
+    getDicts('risk_color')
+  ])
+  riskLevelDict.value = levelRes.data
+  riskColorDict.value = colorRes.data
+}
+
+async function initMap() {
+  await nextTick()
+  const el = document.getElementById('riskMap')
+  if (!el) return
+  try {
+    const L = await import('leaflet')
+    mapInstance = L.map('riskMap').setView([34.77, 113.65], 12)
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; OpenStreetMap contributors'
+    }).addTo(mapInstance)
+
+    const res = await getFourColorMap()
+    const items = res.data || []
+    for (const item of items) {
+      if (!item.longitude || !item.latitude) continue
+      const color = colorMap[item.riskColor] || '#999'
+      const marker = L.circleMarker([item.latitude, item.longitude], {
+        radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9
+      }).addTo(mapInstance)
+      const levelLabel = getDictLabel(riskLevelDict, item.riskLevel)
+      marker.bindPopup(`<b>${item.networkName || item.networkId}</b><br/>${levelLabel}<br/>评分: ${item.riskScore}`)
+      marker.on('click', () => { currentItem.value = item; detailVisible.value = true })
+    }
+  } catch (e) {
+    el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">请安装Leaflet: npm install leaflet</div>'
+  }
+}
+
+onMounted(async () => { await loadDicts(); initMap() })
+</script>
+
+<style scoped>
+.risk-map-container { position: relative; height: calc(100vh - 100px); padding: 16px; }
+.legend-card { position: absolute; top: 32px; right: 32px; z-index: 1000; width: 180px; }
+.legend-items { display: flex; flex-direction: column; gap: 6px; }
+.legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
+.line { width: 30px; height: 4px; border-radius: 2px; display: inline-block; }
+.map { width: 100%; height: 100%; border-radius: 4px; }
+</style>