LAPTOP-JI2IUVG1\26646 преди 1 месец
родител
ревизия
4dbb5aa0d2

+ 11 - 6
src/api/pipeNetwork/basic.js

@@ -227,19 +227,24 @@ export function getGasAlarmHistory(pageNum, pageSize, startDate, endDate, device
 export function getGasAlarmStats() {
   return request({ url: '/api/gas-alarm/stats', method: 'get' })
 }
-export function resolveGasAlarm(id) {
-  return request({ url: '/api/gas-alarm/resolve/' + id, method: 'put' })
+export function resolveGasAlarm(id, handleRemark) {
+  return request({ url: '/api/gas-alarm/resolve/' + id, method: 'put', params: handleRemark ? { handleRemark } : undefined })
 }
-export function dispatchGasAlarm(id) {
-  return request({ url: '/api/gas-alarm/dispatch/' + id, method: 'post' })
+export function dispatchGasAlarm(id, data) {
+  return request({ url: '/api/gas-alarm/dispatch/' + id, method: 'post', data })
 }
 
 // 管线压力报警
 export function getPressureAlarmEquipment() { return request({ url: '/api/pressure-alarm/equipment', method: 'get' }) }
 export function getPressureAlarmHistory(pageNum, pageSize, startDate, endDate, deviceCode) { return request({ url: '/api/pressure-alarm/history', method: 'get', params: { pageNum, pageSize, startDate, endDate, deviceCode } }) }
 export function getPressureAlarmStats() { return request({ url: '/api/pressure-alarm/stats', method: 'get' }) }
-export function resolvePressureAlarm(id) { return request({ url: '/api/pressure-alarm/resolve/' + id, method: 'put' }) }
-export function dispatchPressureAlarm(id) { return request({ url: '/api/pressure-alarm/dispatch/' + id, method: 'post' }) }
+export function resolvePressureAlarm(id, handleRemark) { return request({ url: '/api/pressure-alarm/resolve/' + id, method: 'put', params: handleRemark ? { handleRemark } : undefined }) }
+export function dispatchPressureAlarm(id, data) { return request({ url: '/api/pressure-alarm/dispatch/' + id, method: 'post', data }) }
+
+// 通用报警转工单(兼容未使用燃气专用报警入口的报警记录)
+export function approveAlarmToWorkOrder(data) {
+  return request({ url: '/api/alarm/approve-to-work-order', method: 'post', data })
+}
 // 培训课程
 export function getCourseList(params) { return request({ url: '/api/drill/course/list', method: 'get', params }) }
 export function saveCourse(data) { return request({ url: '/api/drill/course', method: 'post', data }) }

+ 5 - 1087
src/views/subSystem/basic/GisDashboard.vue

@@ -1,1090 +1,8 @@
-<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>
-
+<!-- 燃气管网 GIS 菜单实际入口,统一复用真实管网数据页面。 -->
 <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'
-import { convertCoord } from '@/utils/coordTransform'
-import { getPipeNetworkOptions, getGasPipePointPage, getManholeList } from '@/api/pipeNetwork/basic'
-
-const centerPoint = { lng: 110.386, lat: 28.445 }
-
-const MAP_BOUNDS = {
-  southWest: { lng: 110.355, lat: 28.428 },
-  northEast: { lng: 110.410, lat: 28.460 }
-}
-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.358, lat: 28.456 },
-  { lng: 110.375, lat: 28.458 },
-  { lng: 110.392, lat: 28.458 },
-  { lng: 110.408, lat: 28.455 },
-  { lng: 110.410, lat: 28.448 },
-  { lng: 110.407, lat: 28.440 },
-  { lng: 110.400, lat: 28.436 },
-  { lng: 110.390, lat: 28.434 },
-  { lng: 110.378, lat: 28.433 },
-  { lng: 110.368, lat: 28.436 },
-  { lng: 110.360, lat: 28.445 }
-]
-
-const pipelineData = [
-  {
-    id: 'pipe-high-01', level: 'high', name: '燃气1', code: 'GX-HP-001', length: 4.1,
-    material: 'L290N 无缝钢管', diameter: 'DN300', pressure: '高压A (4.0MPa)', road: '燃气1主干线',
-    status: '运行正常', layingMethod: '直埋敷设', depth: '1.8m', commissioningDate: '2026-06',
-    points: [{lng:110.404947,lat:28.433419},{lng:110.404836,lat:28.433429},{lng:110.403275,lat:28.433571},{lng:110.401084,lat:28.433876},{lng:110.399601,lat:28.433876},{lng:110.398112,lat:28.433979},{lng:110.396717,lat:28.434314},{lng:110.394299,lat:28.434538},{lng:110.394352,lat:28.434887},{lng:110.391969,lat:28.434985},{lng:110.384374,lat:28.435695},{lng:110.381418,lat:28.43559},{lng:110.377993,lat:28.436193},{lng:110.376062,lat:28.436385},{lng:110.374231,lat:28.43655},{lng:110.372584,lat:28.437107},{lng:110.371292,lat:28.4377},{lng:110.370581,lat:28.438198},{lng:110.369831,lat:28.438821}]
-  },
-  {
-    id: 'pipe-subhigh-01', level: 'subHigh', name: '燃气2', code: 'GX-SHP-002', length: 3.8,
-    material: 'L245N 无缝钢管', diameter: 'DN250', pressure: '次高压A (1.6MPa)', road: '燃气2支线',
-    status: '运行正常', layingMethod: '直埋敷设', depth: '1.6m', commissioningDate: '2026-03',
-    points: [{lng:110.361433,lat:28.454979},{lng:110.362363,lat:28.453169},{lng:110.362851,lat:28.452213},{lng:110.364377,lat:28.452443},{lng:110.365138,lat:28.45246},{lng:110.366462,lat:28.452157},{lng:110.369549,lat:28.451441},{lng:110.37219,lat:28.450897},{lng:110.372753,lat:28.450901},{lng:110.373875,lat:28.451194},{lng:110.373913,lat:28.451107},{lng:110.374597,lat:28.451476}]
-  }
-]
-
-const pipePointData = ref([])
-const manholeData = ref([])
-
-async function loadGisData() {
-  try {
-    const [pointRes, manholeRes] = await Promise.all([
-      getGasPipePointPage(1, 500, {}),
-      getManholeList()
-    ])
-    const points = (pointRes.data && pointRes.data.records) ? pointRes.data.records : []
-    pipePointData.value = points.filter(p => p.longitude && p.latitude).map(p => ({
-      id: 'point-' + p.pointId, type: mapPointType(p.pointType), pipelineId: p.networkId,
-      name: p.pointName, code: p.pointCode, road: p.networkName || '', status: '在线',
-      deviceModel: p.material || '', caliber: p.diameter ? p.diameter + 'mm' : '',
-      lng: Number(p.longitude), lat: Number(p.latitude)
-    }))
-    const manholes = manholeRes.data || []
-    manholeData.value = manholes.filter(m => m.longitude && m.latitude).map(m => ({
-      id: 'manhole-' + m.manholeId, type: 'cover', pipelineId: m.networkId || '',
-      name: m.manholeName, code: m.manholeCode, road: '', status: m.status === '0' ? '完好' : '异常',
-      material: m.material || '', size: m.manholeShape || '', lng: Number(m.longitude), lat: Number(m.latitude)
-    }))
-    if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
-  } catch (e) { console.error(e) }
-}
-
-function mapPointType(type) {
-  if (!type) return 'terminal'
-  if (type.includes('阀门') || type === 'valve') return 'valve'
-  if (type.includes('调压') || type === 'regulator') return 'regulator'
-  if (type.includes('计量') || type.includes('流量') || type === 'flowmeter') return 'flowmeter'
-  return 'terminal'
-}
-
-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.value.length,
-  manholeCount: manholeData.value.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.value.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.value.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()
-  await loadGisData()
-  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 [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-    const centerBMapPoint = new BMapGL.Point(clng, clat)
-    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 { 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 => { const [lng, lat] = convertCoord(item.lng, item.lat); return new BMapGL.Point(lng, 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 => { const [lng, lat] = convertCoord(p.lng, p.lat); return new BMapGL.Point(lng, lat) })
-      const polyline = new BMapGL.Polyline(pts, {
-        strokeColor: colorMap[item.level],
-        strokeWeight: 6,
-        strokeOpacity: 0.92
-      })
-      const firstPt = { lng: convertCoord(item.points[0].lng, item.points[0].lat)[0], lat: convertCoord(item.points[0].lng, item.points[0].lat)[1] }
-      try {
-        polyline.addEventListener('click', (e) => {
-          overlayClicked = true
-          e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-          showPopup(firstPt.lng, firstPt.lat, buildPopupHTML(buildPipelineDetail(item)))
-          selectedFeature.value = buildPipelineDetail(item)
-        })
-      } catch (_) { /* polyline click listener failed */ }
-      mapInstance.addOverlay(polyline)
-
-      const rawMid = getMidPoint(item.points)
-      const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
-      addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.level], item)
-    } catch (e) {
-      console.warn(`绘制管线 ${item.name} 失败:`, e)
-    }
-  })
-}
-
-function drawPipePoints() {
-  pipePointData.value.forEach(item => {
-    try {
-      if (!layerState.pipePoints[item.type]) return
-      const [plng, plat] = convertCoord(item.lng, item.lat)
-      addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
-        showPopup(plng, plat, buildPopupHTML(buildPipePointDetail(item)))
-        selectedFeature.value = buildPipePointDetail(item)
-      })
-    } catch (e) {
-      console.warn(`绘制管点 ${item.name} 失败:`, e)
-    }
-  })
-}
-
-function drawManholes() {
-  manholeData.value.forEach(item => {
-    try {
-      if (!layerState.manholes[item.type]) return
-      const [mlng, mlat] = convertCoord(item.lng, item.lat)
-      addPointMarker(mlng, mlat, colorMap[item.type], item.name, 'square', () => {
-        showPopup(mlng, mlat, 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
-  }
-
-  const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
-  mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
-
-  if (matched.kind === 'pipeline') {
-    selectedFeature.value = buildPipelineDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
-  } else if (matched.kind === 'pipePoint') {
-    selectedFeature.value = buildPipePointDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
-  } else {
-    selectedFeature.value = buildManholeDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
-  }
-
-  ElMessage.success(`已定位到 ${matched.name}`)
-}
-
-async 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()
-  await loadGisData()
-  locateCenter()
-}
-
-function locateCenter() {
-  if (!mapInstance) return
-  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-  mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), 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
-  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-  setTimeout(() => {
-    mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), mapInstance.getZoom())
-  }, 200)
-}
+import GasPipelineGis from '@/views/subSystem/gas/gasBasicadata/gasPipelintNet.vue'
 </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>
+<template>
+  <GasPipelineGis />
+</template>

+ 139 - 9
src/views/subSystem/drainage/home.vue

@@ -57,6 +57,11 @@
       </div>
       <div class="panel-center">
         <div id="rwMap" class="map-container"></div>
+        <div class="pipeline-map-status">
+          <span class="pipeline-status-dot"></span>
+          排水管网 {{ drainagePipelines.length }} 段
+        </div>
+        <div v-if="pipelineMapError" class="pipeline-map-error">{{ pipelineMapError }}</div>
       </div>
       <div class="panel-right">
         <div class="panel-box">
@@ -291,6 +296,8 @@ import * as echarts from 'echarts'
 import locationIcon from '@/assets/images/location.png'
 import { getEquipmentPage, getWaterloggingPage, getPartitionPage, getEquipmentById, getAlarmDataPage } from '@/api/drainage'
 import { getAlarmPage } from '@/api/pipeNetwork/hazard'
+import { getGisPipeLines } from '@/api/pipeNetwork/basic'
+import { convertCoord } from '@/utils/coordTransform'
 
 const BAIDU_MAP_AK = 'mvSNSN4CPN6WJs2Xi6sQFNmWwCLRgU6T'
 const MAP_STYLE_ID = '7a6c3490bbc3d1e3e9d8602530301e10'
@@ -378,6 +385,8 @@ const rwRealtimeData = ref([])
 const rwWarnSummary = ref({ online: 0, offline: 0 })
 const rwWarnData = ref([])
 const rwMapPoints = ref([])
+const drainagePipelines = ref([])
+const pipelineMapError = ref('')
 
 // ==================== Tab 2: 设备管理 ====================
 const deviceOverview = ref({ total: 0, online: 0, offline: 0 })
@@ -415,28 +424,91 @@ const waterStatType = ref('month')
 
 // ==================== 地图实例 ====================
 const rwMap = ref(null), deviceMap = ref(null), waterMap = ref(null)
+let bmapInitTimer = null
 
 function initBMapGL(containerId, mapRef, points, clickHandler) {
   const container = document.getElementById(containerId)
-  if (!container || typeof BMapGL === 'undefined') { console.warn('BMapGL 未加载或容器不存在:', containerId); return }
+  if (!container || typeof BMapGL === 'undefined') return false
   try {
     const map = new BMapGL.Map(containerId)
     map.enableScrollWheelZoom(true)
     map.setMapStyleV2({ styleId: MAP_STYLE_ID })
     mapRef.value = map
+    // 首页中间的雨水污水地图展示 pipe_network_base 中的排水管网;
+    // 设备、积水页签仍只展示各自的业务点位。
+    const viewPoints = containerId === 'rwMap' ? drawDrainagePipelines(map) : []
     if (points.length > 0) {
       points.forEach(p => addLabelMarker(map, p, clickHandler))
-      const viewPoints = points.map(p => new BMapGL.Point(p.lng, p.lat))
+      viewPoints.push(...points.map(p => {
+        const [lng, lat] = convertCoord(Number(p.lng), Number(p.lat))
+        return new BMapGL.Point(lng, lat)
+      }))
+    }
+    if (viewPoints.length > 0) {
       map.setViewport(viewPoints)
-      if (map.getZoom() < 13) map.setZoom(13)
+      if (containerId !== 'rwMap' && map.getZoom() < 13) map.setZoom(13)
     } else {
       map.centerAndZoom(new BMapGL.Point(110.393, 28.452), 14)
     }
-  } catch (e) { console.error('百度地图初始化失败:', e) }
+    return true
+  } catch (e) {
+    console.error('百度地图初始化失败:', e)
+    if (containerId === 'rwMap') pipelineMapError.value = '排水管网地图初始化失败,请刷新页面重试。'
+    return false
+  }
+}
+
+function escapeMapText(value) {
+  return String(value ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+function getPipelineColor(level) {
+  const text = String(level || '').toLowerCase()
+  if (text.includes('主') || text.includes('一级') || text.includes('main')) return '#00e5ff'
+  if (text.includes('支') || text.includes('二级') || text.includes('branch')) return '#409eff'
+  return '#67c23a'
+}
+
+function drawDrainagePipelines(map) {
+  const viewportPoints = []
+  drainagePipelines.value.forEach(line => {
+    if (!Array.isArray(line.points) || line.points.length < 2) return
+    const bmapPoints = line.points.map(point => {
+      const [lng, lat] = convertCoord(point.lng, point.lat)
+      return new BMapGL.Point(lng, lat)
+    })
+    const polyline = new BMapGL.Polyline(bmapPoints, {
+      strokeColor: getPipelineColor(line.level),
+      strokeWeight: 5,
+      strokeOpacity: 0.9
+    })
+    polyline.addEventListener('click', event => {
+      event?.domEvent?.stopPropagation?.()
+      const content = `<div style="min-width:220px;color:#303133;line-height:1.8">
+        <div style="font-size:15px;font-weight:700;color:#1677ff;margin-bottom:6px">${escapeMapText(line.name || '排水管网')}</div>
+        <div>设施编号:${escapeMapText(line.code || '-')}</div>
+        <div>管网等级:${escapeMapText(line.level || '-')}</div>
+        <div>管径:${escapeMapText(line.diameter ?? '-')} mm</div>
+        <div>长度:${escapeMapText(line.length ?? '-')} m</div>
+        <div>材质:${escapeMapText(line.material || '-')}</div>
+        <div>位置:${escapeMapText(line.location || '-')}</div>
+      </div>`
+      map.openInfoWindow(new BMapGL.InfoWindow(content, { width: 280, enableMessage: false }), bmapPoints[0])
+    })
+    map.addOverlay(polyline)
+    viewportPoints.push(...bmapPoints)
+  })
+  return viewportPoints
 }
 
 function addLabelMarker(map, point, clickHandler) {
-  const bPoint = new BMapGL.Point(point.lng, point.lat)
+  const [lng, lat] = convertCoord(Number(point.lng), Number(point.lat))
+  const bPoint = new BMapGL.Point(lng, lat)
   const color = point.color || '#409eff'
   const icon = point.icon || locationIcon
   const html = `<div style="text-align:center;cursor:pointer;">
@@ -494,9 +566,45 @@ function getBarOption(xData, yData, name = '数值', color = '#409eff') {
 
 // ==================== 数据加载(mock + API 占位) ====================
 async function loadAllData() {
-  await loadRwTabData()
-  await loadEquipmentData()
-  await loadWaterloggingData()
+  await Promise.all([
+    loadDrainagePipelineData(),
+    loadRwTabData(),
+    loadEquipmentData(),
+    loadWaterloggingData()
+  ])
+}
+
+async function loadDrainagePipelineData() {
+  pipelineMapError.value = ''
+  try {
+    const response = await getGisPipeLines({ networkType: '1' })
+    const features = Array.isArray(response?.data?.features) ? response.data.features : []
+    drainagePipelines.value = features.map(feature => {
+      const properties = feature?.properties || {}
+      const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
+      const points = coordinates
+        .map(item => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
+        .filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
+      return {
+        id: properties.networkId,
+        code: properties.networkCode,
+        name: properties.networkName,
+        level: properties.networkLevel,
+        diameter: properties.diameter,
+        length: properties.length,
+        material: properties.material,
+        location: properties.location,
+        points
+      }
+    }).filter(line => line.points.length >= 2)
+    if (!drainagePipelines.value.length) {
+      pipelineMapError.value = '暂无带完整起止坐标的排水管网数据'
+    }
+  } catch (error) {
+    drainagePipelines.value = []
+    pipelineMapError.value = '排水管网数据加载失败,请检查后端服务。'
+    console.error('加载排水管网 GIS 线路失败:', error)
+  }
 }
 
 async function loadEquipmentData() {
@@ -947,6 +1055,24 @@ function initTabMaps() {
   initBMapGL('waterMap', waterMap, waterMapPoints.value, null)
 }
 
+function waitForBMapAndInit() {
+  let attempts = 0
+  const maxAttempts = 100
+  const tryInit = () => {
+    attempts += 1
+    if (typeof BMapGL !== 'undefined') {
+      initTabMaps()
+      return
+    }
+    if (attempts >= maxAttempts) {
+      pipelineMapError.value = '百度地图加载超时,请检查网络或地图配置。'
+      return
+    }
+    bmapInitTimer = window.setTimeout(tryInit, 200)
+  }
+  tryInit()
+}
+
 function switchTab(tab) {
   activeTab.value = tab
   nextTick(() => {
@@ -967,7 +1093,7 @@ onMounted(async () => {
   updateRefreshTime()
   await Promise.all([loadAllData(), loadWeatherData()])
   setTimeout(() => {
-    initTabMaps()
+    waitForBMapAndInit()
     initTab1Charts()
     initTab2Charts()
     initTab3Charts()
@@ -976,6 +1102,7 @@ onMounted(async () => {
 
 onUnmounted(() => {
   if (timer) clearInterval(timer)
+  if (bmapInitTimer) window.clearTimeout(bmapInitTimer)
   Object.values(chartInstances).forEach(c => c && c.dispose())
 })
 
@@ -1053,6 +1180,9 @@ window.addEventListener('resize', () => {
 
 /* ==================== 地图 ==================== */
 .map-container { flex: 1; border-radius: 6px; overflow: hidden; border: 1px solid rgba(64, 158, 255, 0.2); min-height: 0; }
+.pipeline-map-status { position: absolute; top: 12px; left: 12px; z-index: 5; display: flex; align-items: center; gap: 7px; padding: 7px 12px; color: #d9f4ff; background: rgba(6, 30, 65, 0.86); border: 1px solid rgba(0, 229, 255, 0.42); border-radius: 4px; font-size: 12px; pointer-events: none; }
+.pipeline-status-dot { width: 9px; height: 9px; background: #00e5ff; border-radius: 50%; box-shadow: 0 0 8px rgba(0, 229, 255, 0.9); }
+.pipeline-map-error { position: absolute; left: 50%; bottom: 18px; z-index: 5; max-width: 70%; padding: 8px 14px; color: #ffd591; background: rgba(80, 45, 0, 0.88); border: 1px solid rgba(230, 162, 60, 0.55); border-radius: 4px; font-size: 12px; text-align: center; transform: translateX(-50%); pointer-events: none; }
 .chart-area-sm { width: 100%; flex: 1; min-height: 130px; }
 
 /* ==================== 预警卡片 ==================== */

+ 160 - 5
src/views/subSystem/drainage/jcsj/gwyxqk.vue

@@ -89,6 +89,11 @@
           <span class="search-icon">🔍</span>
         </div>
         <div id="gwyxqkMap" class="map-container"></div>
+        <div class="pipeline-map-status">
+          <span class="pipeline-status-dot"></span>
+          排水管线 {{ gisPipelines.length }} 段
+        </div>
+        <div v-if="pipelineMapError" class="pipeline-map-error">{{ pipelineMapError }}</div>
       </div>
 
       <!-- ========== 右侧面板 ========== -->
@@ -175,6 +180,8 @@ import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
 import * as echarts from 'echarts'
 import locationIcon from '@/assets/images/location.png'
 import { getPipeNetworkPage, getEquipmentPage, getAlarmDataPage, getRadarDataByCode, getRadarDataHistory } from '@/api/drainage'
+import { getGisPipeLines } from '@/api/pipeNetwork/basic'
+import { convertCoord } from '@/utils/coordTransform'
 
 // ==================== 实时时间 ====================
 const currentDateTime = ref('')
@@ -228,6 +235,8 @@ const alarmList = ref([])
 // ==================== 地图数据(从API加载)====================
 const mapPoints = ref([])
 const pipeNetworkData = ref([])
+const gisPipelines = ref([])
+const pipelineMapError = ref('')
 
 // ==================== 设备状态映射 ====================
 function getEquipStatusInfo(equipment) {
@@ -251,6 +260,7 @@ function extractPage(res) {
 // ==================== 数据加载 ====================
 async function loadMapData() {
   try {
+    await loadGisPipelineData()
     // 1. 获取所有管网
     const pnRes = await getPipeNetworkPage(1, 999, {})
     const pipeNetworks = extractPage(pnRes)
@@ -381,6 +391,37 @@ async function loadMapData() {
   }
 }
 
+async function loadGisPipelineData() {
+  pipelineMapError.value = ''
+  try {
+    const response = await getGisPipeLines({ networkType: '1' })
+    const features = Array.isArray(response?.data?.features) ? response.data.features : []
+    gisPipelines.value = features.map(feature => {
+      const properties = feature?.properties || {}
+      const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
+      const points = coordinates
+        .map(item => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
+        .filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
+      return {
+        id: properties.networkId,
+        code: properties.networkCode,
+        name: properties.networkName || properties.networkCode || '排水管网',
+        level: properties.networkLevel,
+        diameter: properties.diameter,
+        length: properties.length,
+        material: properties.material,
+        location: properties.location,
+        points
+      }
+    }).filter(line => line.points.length >= 2)
+    if (!gisPipelines.value.length) pipelineMapError.value = '暂无带完整起止坐标的排水管网数据'
+  } catch (error) {
+    gisPipelines.value = []
+    pipelineMapError.value = '排水管网数据加载失败,请检查后端服务。'
+    console.error('[gwyxqk] 排水管网 GIS 线路加载失败:', error)
+  }
+}
+
 // ==================== 实时数据加载(/latest 返回单条 RadarData 对象)====================
 async function loadRealtimeData(equipmentList, pipeNetworks) {
   console.log('[gwyxqk] loadRealtimeData 收到设备:', equipmentList.length, '条', equipmentList.map(e => e.equipmentCode))
@@ -468,6 +509,7 @@ function loadAlarmData(alarmRecords, equipmentList) {
 
 // ==================== 地图初始化 ====================
 let mapInstance = null
+let mapInitTimer = null
 
 function initMap() {
   const container = document.getElementById('gwyxqkMap')
@@ -485,23 +527,76 @@ function initMap() {
       centerLng = mapPoints.value[0].lng
       centerLat = mapPoints.value[0].lat
     }
-    const centerPoint = new BMapGL.Point(centerLng, centerLat)
+    const [mapCenterLng, mapCenterLat] = convertCoord(centerLng, centerLat)
+    const centerPoint = new BMapGL.Point(mapCenterLng, mapCenterLat)
     mapInstance.centerAndZoom(centerPoint, 14)
     mapInstance.enableScrollWheelZoom(true)
 
-    // 地图只显示实时数据中的设备点位,自动定位到第一个设备
-    console.log('[gwyxqk] 初始化地图,设备点数量:', mapPoints.value.length)
+    const viewportPoints = drawGisPipelines()
+    // 叠加实时数据中的设备点位,并让视野覆盖管线和设备。
+    console.log('[gwyxqk] 初始化地图,排水管线数量:', gisPipelines.value.length, '设备点数量:', mapPoints.value.length)
     mapPoints.value.forEach(point => {
       addMapMarker(point)
+      const [lng, lat] = convertCoord(point.lng, point.lat)
+      viewportPoints.push(new BMapGL.Point(lng, lat))
     })
+    if (viewportPoints.length > 0) mapInstance.setViewport(viewportPoints)
   } catch (e) {
     console.error('百度地图初始化失败:', e)
   }
 }
 
+function getGisPipelineColor(level) {
+  const text = String(level || '').toLowerCase()
+  if (text.includes('主') || text.includes('一级') || text.includes('main')) return '#00e5ff'
+  if (text.includes('支') || text.includes('二级') || text.includes('branch')) return '#409eff'
+  return '#67c23a'
+}
+
+function escapeMapText(value) {
+  return String(value ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+function drawGisPipelines() {
+  const viewportPoints = []
+  gisPipelines.value.forEach(line => {
+    const bmapPoints = line.points.map(point => {
+      const [lng, lat] = convertCoord(point.lng, point.lat)
+      return new BMapGL.Point(lng, lat)
+    })
+    const polyline = new BMapGL.Polyline(bmapPoints, {
+      strokeColor: getGisPipelineColor(line.level),
+      strokeWeight: 5,
+      strokeOpacity: 0.9
+    })
+    polyline.addEventListener('click', event => {
+      event?.domEvent?.stopPropagation?.()
+      const content = `<div style="min-width:220px;color:#303133;line-height:1.8">
+        <div style="font-size:15px;font-weight:700;color:#1677ff;margin-bottom:6px">${escapeMapText(line.name)}</div>
+        <div>设施编号:${escapeMapText(line.code || '-')}</div>
+        <div>管网等级:${escapeMapText(line.level || '-')}</div>
+        <div>管径:${escapeMapText(line.diameter ?? '-')} mm</div>
+        <div>长度:${escapeMapText(line.length ?? '-')} m</div>
+        <div>材质:${escapeMapText(line.material || '-')}</div>
+        <div>位置:${escapeMapText(line.location || '-')}</div>
+      </div>`
+      mapInstance.openInfoWindow(new BMapGL.InfoWindow(content, { width: 280, enableMessage: false }), bmapPoints[0])
+    })
+    mapInstance.addOverlay(polyline)
+    viewportPoints.push(...bmapPoints)
+  })
+  return viewportPoints
+}
+
 function addMapMarker(point) {
   const color = point.color || '#409eff'
-  const bPoint = new BMapGL.Point(point.lng, point.lat)
+  const [lng, lat] = convertCoord(point.lng, point.lat)
+  const bPoint = new BMapGL.Point(lng, lat)
 
   const html = `<div style="text-align:center;cursor:pointer;">
     <div style="color:#fff;background:${color};border-radius:4px;padding:2px 6px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;">${point.name}</div>
@@ -899,7 +994,7 @@ onMounted(async () => {
 
   // 延迟初始化确保 DOM 渲染完成
   setTimeout(() => {
-    initMap()
+    waitForBMapAndInit()
     initPieChart()
     // 加载默认设备的曲线历史数据
     if (selectedCurveDevice.value) {
@@ -913,8 +1008,26 @@ onMounted(async () => {
   }, 300)
 })
 
+function waitForBMapAndInit() {
+  let attempts = 0
+  const tryInit = () => {
+    attempts += 1
+    if (typeof BMapGL !== 'undefined') {
+      initMap()
+      return
+    }
+    if (attempts >= 100) {
+      pipelineMapError.value = '百度地图加载超时,请检查网络或地图配置。'
+      return
+    }
+    mapInitTimer = window.setTimeout(tryInit, 200)
+  }
+  tryInit()
+}
+
 onUnmounted(() => {
   if (timer) clearInterval(timer)
+  if (mapInitTimer) clearTimeout(mapInitTimer)
   if (pieChart) pieChart.dispose()
   if (lineChart) lineChart.dispose()
   if (barChart) barChart.dispose()
@@ -1233,6 +1346,48 @@ window.addEventListener('resize', () => {
   align-items: center;
 }
 
+.pipeline-map-status {
+  position: absolute;
+  top: 48px;
+  left: 12px;
+  z-index: 5;
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  padding: 7px 12px;
+  color: #d9f4ff;
+  background: rgba(6, 30, 65, 0.86);
+  border: 1px solid rgba(0, 229, 255, 0.42);
+  border-radius: 4px;
+  font-size: 12px;
+  pointer-events: none;
+}
+
+.pipeline-status-dot {
+  width: 9px;
+  height: 9px;
+  background: #00e5ff;
+  border-radius: 50%;
+  box-shadow: 0 0 8px rgba(0, 229, 255, 0.9);
+}
+
+.pipeline-map-error {
+  position: absolute;
+  left: 50%;
+  bottom: 18px;
+  z-index: 5;
+  max-width: 70%;
+  padding: 8px 14px;
+  color: #ffd591;
+  background: rgba(80, 45, 0, 0.88);
+  border: 1px solid rgba(230, 162, 60, 0.55);
+  border-radius: 4px;
+  font-size: 12px;
+  text-align: center;
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+
 .search-input {
   background: rgba(6, 30, 65, 0.85);
   border: 1px solid rgba(64, 158, 255, 0.3);

+ 121 - 505
src/views/subSystem/gas/gasBasicadata/gasPipelintNet.vue

@@ -1,130 +1,75 @@
-<!--燃气管网基础设施gis一张图-->
+<!-- 燃气管网基础设施 GIS 一张图:线路数据来自 pipe_network_base 的燃气设施(network_type=0)。 -->
 <template>
   <div class="gas-gis-dashboard">
-    <!-- 页面头部 -->
     <div class="page-header">
       <div class="header-left">
         <el-icon><MapLocation /></el-icon>
         <span class="title">燃气管网GIS一张图</span>
         <div class="stats-badge">
-          <span class="stat-item"><i class="dot pipeline-dot"></i>管线 {{ pipelineCount }}km</span>
-          <span class="stat-item"><i class="dot point-dot"></i>燃气管点 {{ gasPointCount }}</span>
-          <span class="stat-item"><i class="dot manhole-dot"></i>窨井 {{ manholeCount }}</span>
+          <span class="stat-item"><i class="dot pipeline-dot"></i>管线 {{ pipelineCount }} 段</span>
+          <span class="stat-item"><i class="dot point-dot"></i>总长度 {{ pipelineTotalLength }} km</span>
         </div>
       </div>
       <div class="header-right">
-        <el-input
-            v-model="searchKeyword"
-            placeholder="搜索管点/井盖编号/位置"
-            clearable
-            :prefix-icon="Search"
-            style="width: 240px"
-            size="default"
-        />
-        <el-button type="primary" :icon="Refresh" @click="refreshMap">刷新</el-button>
+        <el-input v-model="searchKeyword" placeholder="搜索管线名称/编号" clearable :prefix-icon="Search" style="width: 240px" />
+        <el-button type="primary" :icon="Refresh" :loading="loading" @click="loadPipelines">刷新</el-button>
         <el-button :icon="FullScreen" @click="fitBounds">全图</el-button>
       </div>
     </div>
 
-    <!-- 主内容区:左侧图层控制 + 右侧GIS地图 -->
     <div class="main-layout">
-      <!-- 左侧图层控制面板 -->
       <div class="layer-panel">
-        <div class="layer-header">
-          <el-icon></el-icon> 图层控制
-        </div>
-        <div class="layer-tree">
-          <el-checkbox-group v-model="visibleLayers">
-            <div class="layer-group">
-              <div class="group-title">燃气管线</div>
-              <el-checkbox value="highPressure">高压管线</el-checkbox>
-              <el-checkbox value="mediumPressure">中压管线</el-checkbox>
-              <el-checkbox value="lowPressure">低压管线</el-checkbox>
-            </div>
-            <div class="layer-group">
-              <div class="group-title">燃气管点</div>
-              <el-checkbox value="valve">阀门</el-checkbox>
-              <el-checkbox value="regulator">调压器</el-checkbox>
-              <el-checkbox value="meter">流量计</el-checkbox>
-              <el-checkbox value="terminal">终端节点</el-checkbox>
-            </div>
-            <div class="layer-group">
-              <div class="group-title">窨井设施</div>
-              <el-checkbox value="manhole">窨井盖</el-checkbox>
-              <el-checkbox value="inspection">检查井</el-checkbox>
-            </div>
-          </el-checkbox-group>
-        </div>
+        <div class="layer-header"><el-icon><Grid /></el-icon> 图层控制</div>
+        <el-checkbox-group v-model="visibleLayers">
+          <div class="layer-group">
+            <div class="group-title">燃气管线</div>
+            <el-checkbox value="highPressure">高压管线</el-checkbox>
+            <el-checkbox value="mediumPressure">中压管线</el-checkbox>
+            <el-checkbox value="lowPressure">低压管线</el-checkbox>
+            <el-checkbox value="other">其他/未分级管线</el-checkbox>
+          </div>
+        </el-checkbox-group>
         <div class="legend-panel">
           <div class="legend-title">图例说明</div>
           <div><span class="legend-line high"></span> 高压管线</div>
           <div><span class="legend-line medium"></span> 中压管线</div>
           <div><span class="legend-line low"></span> 低压管线</div>
-          <div><span class="legend-point valve"></span> 阀门</div>
-          <div><span class="legend-point regulator"></span> 调压器</div>
-          <div><span class="legend-point meter"></span> 流量计</div>
-          <div><span class="legend-point manhole"></span> 窨井</div>
+          <div><span class="legend-line other"></span> 其他/未分级</div>
         </div>
         <div class="info-stats">
-          <div class="info-title">测绘数据统计</div>
-          <div>管线总长: {{ pipelineTotalLength }} km</div>
-          <div>管点总数: {{ gasPointCount }}</div>
-          <div>窨井总数: {{ manholeCount }}</div>
-          <div>覆盖面积: 156.8 km²</div>
+          <div class="info-title">燃气管网数据统计</div>
+          <div>管线总数:{{ pipelineCount }} 段</div>
+          <div>管线总长:{{ pipelineTotalLength }} km</div>
+          <div>数据来源:pipe_network_base</div>
+          <div>设施类型:0-燃气设施</div>
         </div>
+        <el-alert v-if="errorMessage" :title="errorMessage" type="warning" :closable="false" show-icon />
       </div>
 
-      <!-- 右侧GIS地图区域 -->
       <div class="gis-container">
         <div class="map-toolbar">
           <el-button-group>
             <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
             <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
-            <el-button size="small" :icon="Location" @click="locateCurrent">定位</el-button>
+            <el-button size="small" :icon="Location" @click="fitBounds">定位</el-button>
           </el-button-group>
-          <div class="coordinate-info">
-            当前坐标: {{ currentLng.toFixed(4) }}, {{ currentLat.toFixed(4) }}
-          </div>
+          <div class="coordinate-info">燃气管线实时地图</div>
         </div>
-        <div class="map-wrapper" ref="mapContainer">
-          <canvas id="gasMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="updateCoordinates"></canvas>
-          <!-- 悬浮信息卡 -->
-          <div v-if="hoverFeature" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverFeature.name }}</div>
-            <div>类型: {{ hoverFeature.typeName }}</div>
-            <div v-if="hoverFeature.pressure">压力等级: {{ hoverFeature.pressure }}</div>
-            <div v-if="hoverFeature.diameter">管径: {{ hoverFeature.diameter }}mm</div>
-            <div v-if="hoverFeature.material">材质: {{ hoverFeature.material }}</div>
-            <div>位置: {{ hoverFeature.address || hoverFeature.location }}</div>
-          </div>
+        <div class="map-wrapper">
+          <GisMapCanvas ref="mapRef" :lines="visibleLines" :center="mapCenter" :zoom="14" :show-marker-name="false" :enable-clustering="false" @ready="handleMapReady" @line-click="showPipeline" @error="errorMessage = $event" />
+          <div v-if="!loading && !visibleLines.length" class="empty-map">暂无带起止坐标的燃气管线数据</div>
         </div>
-
-        <!-- 右侧详情抽屉 -->
-        <el-drawer v-model="detailDrawerVisible" :title="`设施详情 - ${currentFeature?.name}`" size="420px" direction="rtl" class="detail-drawer">
-          <div v-if="currentFeature" class="feature-detail">
-            <el-descriptions :column="1" border>
-              <el-descriptions-item label="设施名称">{{ currentFeature.name }}</el-descriptions-item>
-              <el-descriptions-item label="设施类型">{{ currentFeature.typeName }}</el-descriptions-item>
-              <el-descriptions-item label="设施编号">{{ currentFeature.code || '—' }}</el-descriptions-item>
-              <el-descriptions-item label="详细地址">{{ currentFeature.address || currentFeature.location }}</el-descriptions-item>
-              <el-descriptions-item label="经纬度">{{ currentFeature.lng }}, {{ currentFeature.lat }}</el-descriptions-item>
-              <el-descriptions-item label="所属区域">{{ currentFeature.district || '浦东新区' }}</el-descriptions-item>
-              <el-descriptions-item label="权属单位">{{ currentFeature.ownerUnit || '燃气集团' }}</el-descriptions-item>
-              <el-descriptions-item label="建设年份">{{ currentFeature.buildYear || '2020' }}</el-descriptions-item>
-              <el-descriptions-item v-if="currentFeature.pressure" label="压力等级">{{ currentFeature.pressure }}</el-descriptions-item>
-              <el-descriptions-item v-if="currentFeature.diameter" label="管径">{{ currentFeature.diameter }}mm</el-descriptions-item>
-              <el-descriptions-item v-if="currentFeature.material" label="材质">{{ currentFeature.material }}</el-descriptions-item>
-              <el-descriptions-item v-if="currentFeature.manholeType" label="井盖类型">{{ currentFeature.manholeType }}</el-descriptions-item>
-            </el-descriptions>
-            <div class="history-section" v-if="currentFeature.records?.length">
-              <div class="sub-title">运维记录</div>
-              <el-timeline>
-                <el-timeline-item v-for="record in currentFeature.records" :key="record.id" :timestamp="record.time" :type="record.type">
-                  {{ record.content }}
-                </el-timeline-item>
-              </el-timeline>
-            </div>
-          </div>
+        <el-drawer v-model="detailDrawerVisible" :title="`管线详情 - ${currentPipeline?.name || ''}`" size="420px" direction="rtl">
+          <el-descriptions v-if="currentPipeline" :column="1" border size="small">
+            <el-descriptions-item label="设施名称">{{ currentPipeline.name || '—' }}</el-descriptions-item>
+            <el-descriptions-item label="设施编号">{{ currentPipeline.code || '—' }}</el-descriptions-item>
+            <el-descriptions-item label="设施类型">{{ currentPipeline.networkTypeName }}</el-descriptions-item>
+            <el-descriptions-item label="管网等级">{{ currentPipeline.level || '—' }}</el-descriptions-item>
+            <el-descriptions-item label="管径">{{ currentPipeline.diameter || '—' }} mm</el-descriptions-item>
+            <el-descriptions-item label="材质">{{ currentPipeline.material || '—' }}</el-descriptions-item>
+            <el-descriptions-item label="长度">{{ currentPipeline.length || '—' }} m</el-descriptions-item>
+            <el-descriptions-item label="位置描述">{{ currentPipeline.location || '—' }}</el-descriptions-item>
+          </el-descriptions>
         </el-drawer>
       </div>
     </div>
@@ -132,427 +77,98 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { MapLocation, Search, Refresh, FullScreen, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
-
-// 可见图层
-const visibleLayers = ref(['highPressure', 'mediumPressure', 'lowPressure', 'valve', 'regulator', 'meter', 'manhole'])
-
-// --- 模拟燃气管线数据 (线段)---
-const pipelines = ref([
-  { id: 'P1', name: '张杨路高压管线', type: 'highPressure', pressure: '高压', diameter: 500, material: '钢管', color: '#f56c6c', lineWidth: 4,
-    coordinates: [[121.48, 31.24], [121.50, 31.245], [121.52, 31.24], [121.54, 31.235]] },
-  { id: 'P2', name: '世纪大道中压管线', type: 'mediumPressure', pressure: '中压', diameter: 300, material: 'PE管', color: '#e6a23c', lineWidth: 3,
-    coordinates: [[121.49, 31.23], [121.51, 31.228], [121.53, 31.225], [121.55, 31.22]] },
-  { id: 'P3', name: '浦东南路低压管线', type: 'lowPressure', pressure: '低压', diameter: 150, material: 'PE管', color: '#67c23a', lineWidth: 2,
-    coordinates: [[121.485, 31.255], [121.50, 31.25], [121.515, 31.248], [121.53, 31.245]] },
-  { id: 'P4', name: '龙阳路中压管线', type: 'mediumPressure', pressure: '中压', diameter: 250, material: '钢管', color: '#e6a23c', lineWidth: 3,
-    coordinates: [[121.47, 31.22], [121.49, 31.218], [121.51, 31.215], [121.53, 31.212]] }
-])
-
-// 模拟燃气管点数据
-const gasPoints = ref([
-  { id: 'V1', name: '世纪大道阀门井', type: 'valve', typeName: '阀门', code: 'VAL-001', lng: 121.505, lat: 31.235, address: '世纪大道与浦电路口', district: '浦东新区', ownerUnit: '燃气集团输配分公司', buildYear: '2019', records: [] },
-  { id: 'V2', name: '张杨路调压站', type: 'regulator', typeName: '调压器', code: 'REG-012', lng: 121.52, lat: 31.242, address: '张杨路628号', district: '浦东新区', ownerUnit: '燃气集团', buildYear: '2018', records: [] },
-  { id: 'V3', name: '龙阳路流量计', type: 'meter', typeName: '流量计', code: 'MET-005', lng: 121.51, lat: 31.215, address: '龙阳路2000号', district: '浦东新区', ownerUnit: '燃气集团', buildYear: '2020', records: [] },
-  { id: 'V4', name: '浦电路终端节点', type: 'terminal', typeName: '终端节点', code: 'TER-023', lng: 121.49, lat: 31.23, address: '浦电路400号', district: '浦东新区', ownerUnit: '燃气集团', buildYear: '2021', records: [] },
-  { id: 'V5', name: '东方路阀门', type: 'valve', typeName: '阀门', code: 'VAL-045', lng: 121.53, lat: 31.248, address: '东方路与浦建路口', district: '浦东新区', ownerUnit: '燃气集团', buildYear: '2019', records: [] }
-])
-
-// 模拟窨井数据
-const manholes = ref([
-  { id: 'M1', name: '世纪大道窨井', type: 'manhole', typeName: '窨井盖', code: 'MH-1001', lng: 121.50, lat: 31.238, address: '世纪大道福山路口', manholeType: '燃气检查井', depth: 2.5, diameter: 0.8, ownerUnit: '燃气集团', records: [] },
-  { id: 'M2', name: '张杨路检查井', type: 'inspection', typeName: '检查井', code: 'IN-023', lng: 121.515, lat: 31.244, address: '张杨路松林路口', manholeType: '阀门井', depth: 3.0, diameter: 1.2, ownerUnit: '燃气集团', records: [] },
-  { id: 'M3', name: '浦电路窨井', type: 'manhole', typeName: '窨井盖', code: 'MH-1005', lng: 121.49, lat: 31.228, address: '浦电路向城路口', manholeType: '燃气检查井', depth: 2.2, diameter: 0.7, ownerUnit: '燃气集团', records: [] },
-  { id: 'M4', name: '龙阳路检查井', type: 'inspection', typeName: '检查井', code: 'IN-045', lng: 121.525, lat: 31.218, address: '龙阳路白杨路口', manholeType: '调压箱井', depth: 2.8, diameter: 1.0, ownerUnit: '燃气集团', records: [] },
-  { id: 'M5', name: '东方路窨井', type: 'manhole', typeName: '窨井盖', code: 'MH-1012', lng: 121.535, lat: 31.25, address: '东方路蓝村路口', manholeType: '燃气检查井', depth: 2.3, diameter: 0.8, ownerUnit: '燃气集团', records: [] }
-])
-
-// 统计数据
-const pipelineTotalLength = computed(() => {
-  // 模拟计算管线总长
-  return 48.6
-})
-const pipelineCount = computed(() => 48.6)
-const gasPointCount = computed(() => gasPoints.value.length)
-const manholeCount = computed(() => manholes.value.length)
-
-// GIS画布相关
-const canvasRef = ref(null)
-const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-let dragStart = null
-let isDragging = false
-const bounds = { minLng: 121.45, maxLng: 121.56, minLat: 31.20, maxLat: 31.27 }
-
-// 交互状态
-const currentLng = ref(121.505)
-const currentLat = ref(31.235)
-const hoverFeature = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
-const currentFeature = ref(null)
-const detailDrawerVisible = ref(false)
+import { computed, nextTick, onMounted, ref } from 'vue'
+import { MapLocation, Grid, Search, Refresh, FullScreen, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getGisPipeLines } from '@/api/pipeNetwork/basic'
+
+const mapRef = ref(null)
+const loading = ref(false)
+const errorMessage = ref('')
 const searchKeyword = ref('')
-
-// 坐标转换
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function canvasToWorld(x, y) {
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  const px = (x - cx - offsetX) / mapZoom + cx
-  const py = (y - cy - offsetY) / mapZoom + cy
-  const lng = bounds.minLng + (px / mapWidth) * (bounds.maxLng - bounds.minLng)
-  const lat = bounds.maxLat - (py / mapHeight) * (bounds.maxLat - bounds.minLat)
-  return { lng, lat }
-}
-
-// 绘制底图
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf5e9'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-
-  // 绘制网格
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i <= 10; i++) {
-    const x = (i / 10) * mapWidth
-    ctx.beginPath()
-    ctx.moveTo(x, 0)
-    ctx.lineTo(x, mapHeight)
-    ctx.stroke()
-    const y = (i / 10) * mapHeight
-    ctx.beginPath()
-    ctx.moveTo(0, y)
-    ctx.lineTo(mapWidth, y)
-    ctx.stroke()
-  }
-
-  // 绘制道路示意
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('世纪大道', 350, 120)
-  ctx.fillText('张杨路', 450, 280)
-  ctx.fillText('龙阳路', 200, 370)
-  ctx.fillText('东方路', 580, 200)
-}
-
-// 绘制管线
-function drawPipelines() {
-  pipelines.value.forEach(pipeline => {
-    if (!visibleLayers.value.includes(pipeline.type)) return
-
-    const points = pipeline.coordinates.map(coord => worldToCanvas(coord[0], coord[1]))
-    if (points.length < 2) return
-
-    ctx.beginPath()
-    ctx.moveTo(points[0].x, points[0].y)
-    for (let i = 1; i < points.length; i++) {
-      ctx.lineTo(points[i].x, points[i].y)
-    }
-    ctx.strokeStyle = pipeline.color
-    ctx.lineWidth = pipeline.lineWidth * mapZoom
-    ctx.stroke()
-
-    // 绘制管线名称标签
-    const midIdx = Math.floor(points.length / 2)
-    if (points[midIdx]) {
-      ctx.fillStyle = '#333'
-      ctx.font = '10px "Microsoft YaHei"'
-      ctx.shadowBlur = 0
-      ctx.fillText(pipeline.name.slice(0, 8), points[midIdx].x - 20, points[midIdx].y - 5)
-    }
-  })
-}
-
-// 绘制点要素
-function drawPoints(points, type, iconColor, size = 8) {
-  points.forEach(point => {
-    if (!visibleLayers.value.includes(point.type)) return
-
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = iconColor
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-
-    // 绘制小标签
-    ctx.fillStyle = iconColor
-    ctx.font = 'bold 10px sans-serif'
-    ctx.fillText(point.name.slice(0, 4), x - 10, y - 6)
-  })
-}
-
-// 绘制窨井(方形)
-function drawManholesPoints() {
-  manholes.value.forEach(manhole => {
-    if (!visibleLayers.value.includes(manhole.type)) return
-
-    const { x, y } = worldToCanvas(manhole.lng, manhole.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    ctx.fillStyle = '#8B4513'
-    ctx.fillRect(x - 6, y - 6, 12, 12)
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 1.5
-    ctx.strokeRect(x - 6, y - 6, 12, 12)
-    ctx.fillStyle = '#8B4513'
-    ctx.font = '9px sans-serif'
-    ctx.fillText('井', x - 3, y + 3)
-  })
-}
-
-function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-
-  drawBaseMap()
-  drawPipelines()
-  drawPoints(gasPoints.value, 'point', '#409eff', 7)
-  drawManholesPoints()
-}
-
-// 地图操作
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function fitBounds() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
-function locateCurrent() { fitBounds() }
-function refreshMap() { renderMap() }
-
-// 坐标显示更新
-function updateCoordinates(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  const { lng, lat } = canvasToWorld(mouseX, mouseY)
-  currentLng.value = lng
-  currentLat.value = lat
-}
-
-// 点击事件 - 查询最近的管点或窨井
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-
-  let minDist = 25
-  let hit = null
-
-  // 检查燃气管点
-  gasPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) {
-      minDist = dist
-      hit = point
-    }
-  })
-
-  // 检查窨井
-  manholes.value.forEach(manhole => {
-    const { x, y } = worldToCanvas(manhole.lng, manhole.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) {
-      minDist = dist
-      hit = { ...manhole, isManhole: true }
-    }
-  })
-
-  if (hit) {
-    currentFeature.value = hit
-    detailDrawerVisible.value = true
+const visibleLayers = ref(['highPressure', 'mediumPressure', 'lowPressure', 'other'])
+const pipelines = ref([])
+const currentPipeline = ref(null)
+const detailDrawerVisible = ref(false)
+const mapCenter = ref({ lng: 110.3965, lat: 28.4638 })
+
+function normalizeLevel(value) {
+  const text = String(value || '').trim().toLowerCase()
+  if (text.includes('高') || text.includes('high')) return { key: 'highPressure', name: '高压' }
+  if (text.includes('中') || text.includes('medium')) return { key: 'mediumPressure', name: '中压' }
+  if (text.includes('低') || text.includes('low')) return { key: 'lowPressure', name: '低压' }
+  return { key: 'other', name: value || '未分级' }
+}
+
+function normalizeFeature(feature) {
+  const p = feature?.properties || {}
+  const geometry = feature?.geometry
+  const coordinates = geometry?.type === 'LineString' ? geometry.coordinates : []
+  const start = coordinates[0] || [p.startLongitude, p.startLatitude]
+  const end = coordinates[coordinates.length - 1] || [p.endLongitude, p.endLatitude]
+  const points = [start, end].map(item => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) })).filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
+  if (points.length < 2) return null
+  const level = normalizeLevel(p.networkLevel)
+  return {
+    id: p.networkId,
+    code: p.networkCode,
+    name: p.networkName || p.networkCode || p.networkId,
+    networkTypeName: '燃气设施(0)',
+    level: level.name,
+    layer: level.key,
+    material: p.material,
+    diameter: p.diameter,
+    length: p.length,
+    location: p.location,
+    points,
+    color: level.key === 'highPressure' ? '#e34d59' : level.key === 'mediumPressure' ? '#e6a23c' : level.key === 'lowPressure' ? '#32a852' : '#409eff',
+    strokeWeight: level.key === 'highPressure' ? 6 : 5,
+    strokeOpacity: 0.9
   }
 }
 
-// 鼠标悬浮检测
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-
-  let minDist = 20
-  let hit = null
-
-  gasPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) {
-      minDist = dist
-      hit = point
-    }
-  })
-
-  manholes.value.forEach(manhole => {
-    const { x, y } = worldToCanvas(manhole.lng, manhole.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) {
-      minDist = dist
-      hit = { ...manhole, isManhole: true }
-    }
-  })
-
-  if (hit) {
-    hoverFeature.value = hit
-    hoverX.value = e.clientX + 12
-    hoverY.value = e.clientY - 40
-  } else {
-    hoverFeature.value = null
+async function loadPipelines() {
+  loading.value = true
+  errorMessage.value = ''
+  try {
+    const response = await getGisPipeLines({ networkType: '0' })
+    const features = Array.isArray(response?.data?.features) ? response.data.features : []
+    pipelines.value = features.map(normalizeFeature).filter(Boolean)
+    const firstPoint = pipelines.value[0]?.points?.[0]
+    if (firstPoint) mapCenter.value = firstPoint
+    await nextTick()
+    mapRef.value?.fitBounds?.()
+  } catch (error) {
+    pipelines.value = []
+    errorMessage.value = '燃气管网数据加载失败,请检查服务或网络。'
+    ElMessage.error(errorMessage.value)
+  } finally {
+    loading.value = false
   }
 }
 
-// 拖动地图
-function initDragEvents() {
-  const canvas = canvasRef.value
-  if (!canvas) return
-
-  canvas.addEventListener('mousedown', (e) => {
-    dragStart = { x: e.clientX, y: e.clientY, offsetX: offsetX, offsetY: offsetY }
-    isDragging = true
-    canvas.style.cursor = 'grabbing'
-  })
-
-  window.addEventListener('mousemove', (e) => {
-    if (!isDragging) return
-    const dx = e.clientX - dragStart.x
-    const dy = e.clientY - dragStart.y
-    offsetX = dragStart.offsetX + dx
-    offsetY = dragStart.offsetY + dy
-    renderMap()
-  })
-
-  window.addEventListener('mouseup', () => {
-    isDragging = false
-    if (canvasRef.value) canvasRef.value.style.cursor = 'crosshair'
-  })
-}
-
-// 监听图层变化重新渲染
-watch(() => visibleLayers.value, () => renderMap(), { deep: true })
-
-onMounted(() => {
-  nextTick(() => {
-    renderMap()
-    initDragEvents()
-    window.addEventListener('resize', renderMap)
+const visibleLines = computed(() => {
+  const keyword = searchKeyword.value.trim().toLowerCase()
+  return pipelines.value.filter(item => {
+    const matched = !keyword || [item.name, item.code, item.location].some(value => String(value || '').toLowerCase().includes(keyword))
+    return matched && visibleLayers.value.includes(item.layer)
   })
 })
-onUnmounted(() => window.removeEventListener('resize', renderMap))
+const pipelineCount = computed(() => pipelines.value.length)
+const pipelineTotalLength = computed(() => (pipelines.value.reduce((sum, item) => sum + (Number(item.length) || 0), 0) / 1000).toFixed(2))
+function showPipeline(line) { currentPipeline.value = line; detailDrawerVisible.value = true }
+function handleMapReady() { mapRef.value?.fitBounds?.() }
+function fitBounds() { mapRef.value?.fitBounds?.() }
+function zoomIn() { mapRef.value?.zoomIn?.() }
+function zoomOut() { mapRef.value?.zoomOut?.() }
+onMounted(loadPipelines)
 </script>
 
 <style scoped>
-.gas-gis-dashboard {
-  background: #f0f2f6;
-  height: 100vh;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.page-header {
-  background: white;
-  padding: 12px 24px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  border-bottom: 1px solid #e9ecef;
-  flex-shrink: 0;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; color: #1f2d3d; }
-.stats-badge { display: flex; gap: 16px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.dot { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
-.pipeline-dot { background: #f56c6c; }
-.point-dot { background: #409eff; border-radius: 50%; }
-.manhole-dot { background: #8B4513; }
-.header-right { display: flex; gap: 12px; }
-
-.main-layout {
-  display: flex;
-  flex: 1;
-  overflow: hidden;
-  padding: 12px;
-  gap: 12px;
-}
-.layer-panel {
-  width: 260px;
-  background: white;
-  border-radius: 12px;
-  padding: 16px;
-  display: flex;
-  flex-direction: column;
-  gap: 16px;
-  overflow-y: auto;
-  flex-shrink: 0;
-}
-.layer-header { font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
-.layer-group { margin-bottom: 12px; }
-.group-title { font-weight: 500; margin-bottom: 8px; font-size: 13px; color: #606266; }
-.layer-group .el-checkbox { display: block; margin-left: 8px; margin-bottom: 6px; }
-.legend-panel { border-top: 1px solid #e9ecef; padding-top: 12px; }
-.legend-title { font-weight: 500; margin-bottom: 8px; font-size: 13px; }
-.legend-line { display: inline-block; width: 30px; height: 3px; margin-right: 8px; vertical-align: middle; }
-.legend-line.high { background: #f56c6c; }
-.legend-line.medium { background: #e6a23c; }
-.legend-line.low { background: #67c23a; }
-.legend-point { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; }
-.legend-point.valve { background: #409eff; }
-.legend-point.regulator { background: #e6a23c; }
-.legend-point.meter { background: #67c23a; }
-.legend-point.manhole { background: #8B4513; border-radius: 2px; }
-.info-stats { background: #f5f7fa; border-radius: 8px; padding: 12px; font-size: 13px; }
-.info-stats .info-title { font-weight: 600; margin-bottom: 8px; }
-
-.gis-container {
-  flex: 1;
-  background: white;
-  border-radius: 12px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.map-toolbar {
-  padding: 10px 16px;
-  border-bottom: 1px solid #e9ecef;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.coordinate-info { font-size: 12px; color: #909399; }
-.map-wrapper {
-  flex: 1;
-  position: relative;
-  cursor: crosshair;
-}
-canvas { width: 100%; height: 100%; display: block; cursor: crosshair; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-  backdrop-filter: blur(4px);
-}
-.feature-detail { padding: 8px; }
-.sub-title { font-weight: 600; margin: 16px 0 8px 0; }
-</style>
+.gas-gis-dashboard { background: #f0f2f6; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
+.page-header { background: white; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e9ecef; flex-shrink: 0; }
+.header-left, .header-right, .stats-badge { display: flex; align-items: center; gap: 16px; }.header-left .title { font-size: 1.3rem; font-weight: 600; color: #1f2d3d; }.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }.dot { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }.pipeline-dot { background: #e34d59; }.point-dot { background: #409eff; border-radius: 50%; }
+.main-layout { display: flex; flex: 1; overflow: hidden; padding: 12px; gap: 12px; }.layer-panel { width: 260px; background: white; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; flex-shrink: 0; }.layer-header { font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }.group-title, .legend-title, .info-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; color: #606266; }.layer-group .el-checkbox { display: block; margin-left: 8px; margin-bottom: 8px; }.legend-panel { border-top: 1px solid #e9ecef; padding-top: 12px; line-height: 26px; font-size: 13px; }.legend-line { display: inline-block; width: 30px; height: 4px; margin-right: 8px; vertical-align: middle; }.legend-line.high { background: #e34d59; }.legend-line.medium { background: #e6a23c; }.legend-line.low { background: #32a852; }.legend-line.other { background: #409eff; }.info-stats { background: #f5f7fa; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 26px; }
+.gis-container { flex: 1; min-width: 0; background: white; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }.map-toolbar { padding: 10px 16px; border-bottom: 1px solid #e9ecef; display: flex; justify-content: space-between; align-items: center; }.coordinate-info { font-size: 12px; color: #909399; }.map-wrapper { flex: 1; min-height: 420px; position: relative; }.empty-map { position: absolute; inset: 0; display: grid; place-items: center; color: #909399; pointer-events: none; }
+</style>

+ 43 - 12
src/views/subSystem/gas/gasjcyj/gxylyctx.vue

@@ -84,15 +84,15 @@
               <el-button size="small" type="primary" @click.stop="handleAlert(alert)">处理</el-button>
             </div>
           </div>
-          <div v-if="alarmStats.realtimeCount === 0" class="empty-state">
+          <div v-if="realtimeEquipments.length === 0" class="empty-state">
             <el-empty description="暂无实时异常" :image-size="80" />
           </div>
         </div>
-        <div class="pagination-area" v-if="alarmStats.realtimeCount > realtimePageSize">
+        <div class="pagination-area" v-if="realtimeEquipments.length > realtimePageSize">
           <el-pagination
             small
             layout="prev, pager, next"
-            :total="alarmStats.realtimeCount"
+            :total="realtimeEquipments.length"
             :page-size="realtimePageSize"
             v-model:current-page="realtimePage"
             @update:current-page="handleRealtimePageChange"
@@ -181,9 +181,14 @@
         </div>
 
         <!-- 处理操作 -->
-        <div class="action-section" v-if="currentAlert.status !== 'resolved'">
+        <div class="action-section" v-if="currentAlert.status === 'pending'">
           <el-button type="primary" @click="resolveAlert">标记已处理</el-button>
-          <el-button type="warning" @click="dispatchWorkOrder">派发工单</el-button>
+          <el-button
+            type="warning"
+            :loading="dispatching"
+            :disabled="currentAlert.hasWorkOrder"
+            @click="dispatchWorkOrder"
+          >{{ currentAlert.hasWorkOrder ? `已关联工单${currentAlert.workOrderStatusText ? `(${currentAlert.workOrderStatusText})` : ''}` : '派发工单' }}</el-button>
         </div>
       </div>
     </el-drawer>
@@ -207,16 +212,22 @@ const historyPage = ref(1)
 const historyPageSize = 8
 const historyTotal = ref(0)
 const alarmStats = ref({ realtimeCount: 0, pendingCount: 0, maxPressure: 0, totalPoints: 0 })
+const dispatching = ref(false)
 
 async function loadEquipment() {
   try {
     const res = await getPressureAlarmEquipment()
     equipments.value = (res.data || []).map(e => ({ ...e,
-      pointName: e.equipmentName, address: e.location,
+      id: e.latestAlarmId || null,
+      pointCode: e.equipmentCode, pointName: e.equipmentName, address: e.location,
       pressure: e.latestPressure, threshold: e.latestThreshold || 250,
       alertTime: e.latestAlarmTime,
       level: e.hasAlarm ? (e.latestAlarmLevel === 1 ? 'critical' : 'warning') : 'normal',
-      status: e.hasAlarm ? 'pending' : 'normal'
+      status: e.hasAlarm ? 'pending' : 'normal',
+      hasWorkOrder: Boolean(e.latestHasWorkOrder),
+      workOrderId: e.latestWorkOrderId || null,
+      workOrderStatus: e.latestWorkOrderStatus ?? null,
+      workOrderStatusText: e.latestWorkOrderStatusText || ''
     }))
   } catch (e) { console.error(e) }
 }
@@ -227,8 +238,10 @@ async function loadHistory() {
     const res = await getPressureAlarmHistory(historyPage.value, historyPageSize, d?.[0] || '', d?.[1] || '', currentDeviceCode.value || '')
     historyAlerts.value = (res.data?.records || []).map(a => ({
       id: a.id, pointCode: a.deviceCode, pointName: a.pointName, address: a.address,
-      pressure: a.pressure, threshold: a.threshold || 250, alertTime: a.alarmTime, status: a.alarmStatus === 0 ? 'pending' : 'resolved',
-      resolveTime: a.resolveTime, handler: a.handler, remark: a.remark, flow: 0
+       pressure: a.pressure, threshold: a.threshold || 250, alertTime: a.alarmTime, status: a.alarmStatus === 0 ? 'pending' : 'resolved',
+       resolveTime: a.resolveTime, handler: a.handler, remark: a.remark, flow: 0,
+       hasWorkOrder: Boolean(a.hasWorkOrder), workOrderId: a.workOrderId || null,
+       workOrderStatus: a.workOrderStatus ?? null, workOrderStatusText: a.workOrderStatusText || ''
     }))
     historyTotal.value = res.data?.total || 0
   } catch (e) { console.error(e) }
@@ -258,9 +271,10 @@ const dateRange = ref([])
 const historySearch = ref('')
 
 // 实时异常分页
+const realtimeEquipments = computed(() => equipments.value.filter(item => item.hasAlarm))
 const paginatedRealtime = computed(() => {
   const start = (realtimePage.value - 1) * realtimePageSize
-  return equipments.value.slice(start, start + realtimePageSize)
+  return realtimeEquipments.value.slice(start, start + realtimePageSize)
 })
 
 // 历史异常筛选和分页
@@ -312,7 +326,24 @@ const resolveAlert = async () => {
   }
 }
 const dispatchWorkOrder = async () => {
-  if (currentAlert.value?.id) { try { await dispatchPressureAlarm(currentAlert.value.id); ElMessage.success('已派发工单(伪)') } catch (e) { ElMessage.error('派发失败') } }
+  if (!currentAlert.value?.id || currentAlert.value.hasWorkOrder || dispatching.value) return
+  dispatching.value = true
+  try {
+    const res = await dispatchPressureAlarm(currentAlert.value.id, {
+      orderDesc: `${currentAlert.value.pointName || '管线'}压力异常,请现场核实处理`
+    })
+    currentAlert.value.hasWorkOrder = true
+    currentAlert.value.workOrderId = res.data || null
+    currentAlert.value.workOrderStatusText = '待派单'
+    currentAlert.value.status = 'resolved'
+    currentAlert.value.resolveTime = new Date().toLocaleString()
+    ElMessage.success(res.msg || '报警已转为待派单工单')
+    await Promise.all([loadEquipment(), loadHistory(), loadStats()])
+  } catch (e) {
+    ElMessage.error(e?.message || '工单派发失败')
+  } finally {
+    dispatching.value = false
+  }
 }
 async function loadStats() {
   try { const res = await getPressureAlarmStats(); if (res.data) alarmStats.value = res.data } catch (e) { console.error(e) }
@@ -434,4 +465,4 @@ onBeforeUnmount(() => { try { chartInstance?.dispose() } catch {}; chartInstance
 .section-title { font-weight: 600; margin: 16px 0 12px 0; }
 .trend-chart { height: 200px; }
 .action-section { margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end; }
-</style>
+</style>

+ 36 - 11
src/views/subSystem/gas/gasjcyj/krqindbj.vue

@@ -190,9 +190,14 @@
         </div>
 
         <!-- 处理操作 -->
-        <div class="action-section" v-if="currentAlert.status !== 'resolved'">
+        <div class="action-section" v-if="currentAlert.status === 'pending'">
           <el-button type="primary" @click="resolveAlert">标记已处理</el-button>
-          <el-button type="warning" @click="dispatchWorkOrder">派发现场工单</el-button>
+          <el-button
+            type="warning"
+            :loading="dispatching"
+            :disabled="currentAlert.hasWorkOrder"
+            @click="dispatchWorkOrder"
+          >{{ currentAlert.hasWorkOrder ? `已关联工单${currentAlert.workOrderStatusText ? `(${currentAlert.workOrderStatusText})` : ''}` : '派发现场工单' }}</el-button>
         </div>
       </div>
     </el-drawer>
@@ -215,18 +220,25 @@ const historyPage = ref(1)
 const historyPageSize = 8
 const historyTotal = ref(0)
 const alarmStats = ref({ realtimeCount: 0, pendingCount: 0, maxConcentration: 0, totalStations: 0 })
+const dispatching = ref(false)
 
 async function loadEquipment() {
   try {
     const res = await getGasAlarmEquipment()
     equipments.value = (res.data || []).map(e => ({
       ...e,
+      id: e.latestAlarmId || null,
+      stationCode: e.equipmentCode,
       stationName: e.equipmentName,
       concentration: e.latestConcentration,
       threshold: e.latestThreshold || 20,
       alertTime: e.latestAlarmTime,
       level: e.hasAlarm ? (e.latestAlarmLevel === 1 ? 'critical' : 'warning') : 'normal',
-      status: e.hasAlarm ? 'pending' : 'normal'
+      status: e.hasAlarm ? 'pending' : 'normal',
+      hasWorkOrder: Boolean(e.latestHasWorkOrder),
+      workOrderId: e.latestWorkOrderId || null,
+      workOrderStatus: e.latestWorkOrderStatus ?? null,
+      workOrderStatusText: e.latestWorkOrderStatusText || ''
     }))
   } catch (e) { console.error(e) }
 }
@@ -258,7 +270,9 @@ function mapAlarm(a) {
     level: lv, alertTime: a.alarmTime || '', status,
     resolveTime: a.resolveTime || a.handleTime || null,
     handler: a.handler || a.handleUser || null,
-    remark: a.remark || a.handleRemark || null
+       remark: a.remark || a.handleRemark || null,
+       hasWorkOrder: Boolean(a.hasWorkOrder), workOrderId: a.workOrderId || null,
+       workOrderStatus: a.workOrderStatus ?? null, workOrderStatusText: a.workOrderStatusText || ''
   }
 }
 
@@ -327,11 +341,23 @@ const resolveAlert = async () => {
 }
 
 const dispatchWorkOrder = async () => {
-  if (currentAlert.value?.id) {
-    try {
-      await dispatchGasAlarm(currentAlert.value.id)
-      ElMessage.success('已派发现场处置工单(伪)')
-    } catch (e) { ElMessage.error('派发失败') }
+  if (!currentAlert.value?.id || currentAlert.value.hasWorkOrder || dispatching.value) return
+  dispatching.value = true
+  try {
+    const res = await dispatchGasAlarm(currentAlert.value.id, {
+      orderDesc: `${currentAlert.value.stationName || '可燃气体'}浓度报警,请现场核实处理`
+    })
+    currentAlert.value.hasWorkOrder = true
+    currentAlert.value.workOrderId = res.data || null
+    currentAlert.value.workOrderStatusText = '待派单'
+    currentAlert.value.status = 'resolved'
+    currentAlert.value.resolveTime = new Date().toLocaleString()
+    ElMessage.success(res.msg || '报警已转为待派单工单')
+    await Promise.all([loadEquipment(), loadHistory(), loadStats()])
+  } catch (e) {
+    ElMessage.error(e?.message || '工单派发失败')
+  } finally {
+    dispatching.value = false
   }
 }
 
@@ -340,7 +366,6 @@ async function loadStats() {
     const res = await getGasAlarmStats()
     if (res.data) {
       alarmStats.value = res.data
-      totalStations.value = res.data.totalStations || 0
     }
   } catch (e) { console.error(e) }
 }
@@ -478,4 +503,4 @@ onBeforeUnmount(() => {
 .section-title { font-weight: 600; margin: 16px 0 12px 0; }
 .trend-chart { height: 200px; }
 .action-section { margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end; }
-</style>
+</style>

+ 161 - 101
src/views/subSystem/gas/gasjcyj/xldxkjkrqindbj.vue

@@ -62,7 +62,7 @@
             <el-radio-button label="warning">预警</el-radio-button>
           </el-radio-group>
         </div>
-        <div class="alert-list">
+        <div class="alert-list" v-loading="loading">
           <div
               v-for="alert in paginatedAlerts"
               :key="alert.id"
@@ -165,9 +165,24 @@
         <div class="action-section">
           <div class="section-title">研判处置</div>
           <div class="action-buttons">
-            <el-button type="primary" @click="confirmTrueAlarm">确认为真报警</el-button>
-            <el-button type="info" @click="confirmFalseAlarm">排除误报警</el-button>
-            <el-button type="warning" @click="dispatchWorkOrder">派发工单</el-button>
+            <el-button
+              type="primary"
+              :loading="resolving"
+              :disabled="resolving || selectedAlert.status === 'resolved'"
+              @click="confirmTrueAlarm"
+            >确认为真报警</el-button>
+            <el-button
+              type="info"
+              :loading="resolving"
+              :disabled="resolving || selectedAlert.status === 'resolved'"
+              @click="confirmFalseAlarm"
+            >排除误报警</el-button>
+            <el-button
+              type="warning"
+              :loading="dispatching"
+              :disabled="dispatching || resolving || selectedAlert.status === 'resolved' || selectedAlert.hasWorkOrder"
+              @click="dispatchWorkOrder"
+            >{{ workOrderButtonText }}</el-button>
           </div>
           <el-input
               v-model="judgmentRemark"
@@ -201,59 +216,83 @@
 import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
 import { Warning, Refresh, Download, BellFilled, Search, Location, WarningFilled, InfoFilled, DataAnalysis, Clock, WindPower, Document } from '@element-plus/icons-vue'
 import * as echarts from 'echarts'
-import { ElMessage, ElNotification } from 'element-plus'
-
-// 沅陵县相邻地下空间可燃气体实时报警数据
-const realtimeAlerts = ref([
-  { id: 1, pointCode: 'CH4-YL-001', pointName: '辰州北路综合管廊监测点', level: 'critical',
-    address: '沅陵县太常片区辰州北路综合管廊B2层', concentration: 15.8, threshold: 10,
-    alertTime: '2026-06-13 15:23:45', status: 'pending',
-    trendData: [2.1, 2.5, 3.2, 4.0, 5.1, 6.5, 8.0, 9.8, 11.2, 12.5, 13.8, 14.5, 15.2, 15.8, 15.8, 15.5, 15.0, 14.2, 13.5, 12.8, 12.0, 11.2, 10.5, 9.8],
-    historyRecords: [] },
-  { id: 2, pointCode: 'CH4-YL-002', pointName: '古城中路地下管沟监测点', level: 'critical',
-    address: '沅陵县太常片区古城中路电力管沟检查井', concentration: 12.3, threshold: 10,
-    alertTime: '2026-06-13 14:58:12', status: 'pending',
-    trendData: [1.5, 2.2, 3.0, 4.1, 5.5, 7.0, 8.5, 9.2, 10.0, 10.8, 11.5, 12.0, 12.3, 12.3, 12.0, 11.5, 11.0, 10.2, 9.5, 8.8, 8.0, 7.2, 6.5, 5.8],
-    historyRecords: [] },
-  { id: 3, pointCode: 'CH4-YL-003', pointName: '建设路雨水管廊监测点', level: 'warning',
-    address: '沅陵县太常片区建设路雨水管廊检查井', concentration: 6.8, threshold: 10,
-    alertTime: '2026-06-13 14:32:05', status: 'pending',
-    trendData: [2.0, 2.5, 3.2, 4.0, 4.8, 5.5, 6.2, 6.8, 6.8, 6.5, 6.2, 5.8, 5.5, 5.2, 5.0, 4.8, 4.5, 4.2, 4.0, 3.8, 3.5, 3.2, 3.0, 2.8],
-    historyRecords: [] },
-  { id: 4, pointCode: 'CH4-YL-004', pointName: '天宁西路地下车库监测点', level: 'warning',
-    address: '沅陵县太常片区天宁西路商业地下停车场', concentration: 7.5, threshold: 10,
-    alertTime: '2026-06-13 14:15:30', status: 'pending',
-    trendData: [3.0, 3.5, 4.2, 5.0, 5.8, 6.5, 7.0, 7.5, 7.5, 7.2, 6.8, 6.5, 6.2, 6.0, 5.8, 5.5, 5.2, 5.0, 4.8, 4.5, 4.2, 4.0, 3.8, 3.5],
-    historyRecords: [] },
-  { id: 5, pointCode: 'CH4-YL-005', pointName: '滨江东路河堤管廊监测点', level: 'critical',
-    address: '沅陵县太常片区滨江东路河堤综合管廊', concentration: 11.5, threshold: 10,
-    alertTime: '2026-06-13 13:50:00', status: 'pending',
-    trendData: [4.0, 4.5, 5.2, 6.0, 7.0, 8.0, 9.0, 9.8, 10.5, 11.0, 11.5, 11.5, 11.2, 10.8, 10.5, 10.0, 9.5, 9.0, 8.5, 8.0, 7.5, 7.0, 6.5, 6.0],
-    historyRecords: [] },
-  { id: 6, pointCode: 'CH4-YL-006', pointName: '迎宾东路地下通道监测点', level: 'warning',
-    address: '沅陵县太常片区迎宾东路地下人行通道', concentration: 5.2, threshold: 10,
-    alertTime: '2026-06-13 13:30:00', status: 'pending',
-    trendData: [1.2, 1.8, 2.5, 3.2, 4.0, 4.5, 5.0, 5.2, 5.2, 5.0, 4.8, 4.5, 4.2, 4.0, 3.8, 3.5, 3.2, 3.0, 2.8, 2.5, 2.2, 2.0, 1.8, 1.5],
-    historyRecords: [] },
-  { id: 7, pointCode: 'CH4-YL-007', pointName: '古城北路综合管沟监测点', level: 'warning',
-    address: '沅陵县太常片区古城北路综合管沟入口', concentration: 8.2, threshold: 10,
-    alertTime: '2026-06-13 12:10:00', status: 'pending',
-    trendData: [3.5, 4.0, 4.8, 5.5, 6.2, 7.0, 7.5, 8.0, 8.2, 8.2, 8.0, 7.5, 7.0, 6.5, 6.0, 5.5, 5.0, 4.8, 4.5, 4.2, 4.0, 3.8, 3.5, 3.2],
-    historyRecords: [] },
-  { id: 8, pointCode: 'CH4-YL-008', pointName: '建设东路工业园管廊监测点', level: 'critical',
-    address: '沅陵县太常片区建设东路工业园综合管廊', concentration: 14.2, threshold: 10,
-    alertTime: '2026-06-13 11:55:00', status: 'pending',
-    trendData: [5.0, 5.8, 6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.2, 13.8, 14.2, 14.2, 13.8, 13.2, 12.5, 11.8, 11.0, 10.2, 9.5, 8.8, 8.0, 7.2, 6.5],
-    historyRecords: [] }
-])
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
+import { getGasAlarmHistory, resolveGasAlarm, dispatchGasAlarm } from '@/api/pipeNetwork/basic'
+
+// 相邻地下空间报警和场站探测器统一来自 alarm_data;按位置描述筛出地下空间点位。
+const realtimeAlerts = ref([])
+const loading = ref(false)
+const resolving = ref(false)
+const dispatching = ref(false)
+const adjacentKeywords = ['地下', '管廊', '管沟', '车库', '通道', '综合管线', '检查井', '地下空间', '隧道']
+
+function isAdjacentAlarm(alarm) {
+  const text = `${alarm.stationName || ''} ${alarm.address || ''} ${alarm.deviceName || ''} ${alarm.deviceCode || ''}`
+  return adjacentKeywords.some(keyword => text.includes(keyword))
+}
+
+function mapAlarm(alarm) {
+  const concentration = Number(alarm.concentration ?? alarm.actualValue ?? 0)
+  const threshold = Number(alarm.threshold ?? alarm.maxValue ?? 20)
+  const level = Number(alarm.alarmLevel) === 1 ? 'critical' : 'warning'
+  return {
+    id: alarm.id,
+    pointCode: alarm.deviceCode,
+    pointName: alarm.stationName || alarm.deviceName || alarm.deviceCode || '地下空间监测点',
+    address: alarm.address || '',
+    level,
+    concentration,
+    threshold,
+    alertTime: alarm.alarmTime || '',
+    status: Number(alarm.alarmStatus) === 0 ? 'pending' : 'resolved',
+    resolveTime: alarm.resolveTime || null,
+    handler: alarm.handler || null,
+    remark: alarm.remark || alarm.handleRemark || null,
+    trendData: Array(24).fill(concentration),
+    historyRecords: [],
+    hasWorkOrder: Boolean(alarm.hasWorkOrder),
+    workOrderId: alarm.workOrderId || null,
+    workOrderStatus: alarm.workOrderStatus ?? null,
+    workOrderStatusText: alarm.workOrderStatusText || ''
+  }
+}
+
+async function loadAlerts() {
+  loading.value = true
+  const selectedId = selectedAlert.value?.id
+  try {
+    const res = await getGasAlarmHistory(1, 200, '', '', '')
+    const records = res.data?.records || []
+    const pending = records.filter(item => Number(item.alarmStatus) === 0)
+    const adjacent = pending.filter(isAdjacentAlarm)
+    // 设备基础资料尚未补充位置描述时,保留待处理报警,避免页面无数据且无法派单。
+    realtimeAlerts.value = (adjacent.length ? adjacent : pending).map(mapAlarm)
+    selectedAlert.value = realtimeAlerts.value.find(item => item.id === selectedId)
+      || realtimeAlerts.value[0]
+      || null
+    return true
+  } catch (error) {
+    console.error('加载相邻地下空间报警失败', error)
+    realtimeAlerts.value = []
+    selectedAlert.value = null
+    return false
+  } finally {
+    loading.value = false
+  }
+}
 
 // 统计数据
-const totalPoints = computed(() => 12)
+const totalPoints = computed(() => new Set(realtimeAlerts.value.map(alert => alert.pointCode).filter(Boolean)).size)
 const warningCount = computed(() => realtimeAlerts.value.filter(a => a.level === 'warning').length)
 const todayNewCount = 2
-const maxConcentration = computed(() => Math.max(...realtimeAlerts.value.map(a => a.concentration)).toFixed(1))
-const maxConcentrationPoint = computed(() => realtimeAlerts.value.find(a => a.concentration === parseFloat(maxConcentration.value))?.pointName || '—')
-const avgConcentration = computed(() => (realtimeAlerts.value.reduce((sum, a) => sum + a.concentration, 0) / realtimeAlerts.value.length).toFixed(1))
+const maxConcentration = computed(() => realtimeAlerts.value.length ? Math.max(...realtimeAlerts.value.map(a => a.concentration)).toFixed(1) : '0.0')
+const maxConcentrationPoint = computed(() => {
+  const max = Number(maxConcentration.value)
+  return realtimeAlerts.value.find(a => a.concentration === max)?.pointName || '—'
+})
+const avgConcentration = computed(() => realtimeAlerts.value.length
+  ? (realtimeAlerts.value.reduce((sum, a) => sum + a.concentration, 0) / realtimeAlerts.value.length).toFixed(1)
+  : '0.0')
 const avgTrend = 0.6
 const falseAlarmRate = computed(() => 3.8)
 
@@ -262,8 +301,12 @@ const searchKeyword = ref('')
 const alertFilter = ref('all')
 const currentPage = ref(1)
 const pageSize = 3
-const selectedAlert = ref(realtimeAlerts.value[0])
+const selectedAlert = ref(null)
 const judgmentRemark = ref('')
+const workOrderButtonText = computed(() => {
+  if (!selectedAlert.value?.hasWorkOrder) return '派发工单'
+  return `已关联工单${selectedAlert.value.workOrderStatusText ? `(${selectedAlert.value.workOrderStatusText})` : ''}`
+})
 
 const filteredAlerts = computed(() => {
   let list = realtimeAlerts.value
@@ -336,67 +379,84 @@ const selectAlert = (alert) => {
   selectedAlert.value = alert
 }
 
-const confirmTrueAlarm = () => {
-  if (selectedAlert.value.status === 'resolved') return
-  const index = realtimeAlerts.value.findIndex(a => a.id === selectedAlert.value.id)
-  if (index !== -1) {
-    const resolvedAlert = { ...selectedAlert.value, status: 'resolved' }
-    realtimeAlerts.value.splice(index, 1)
-    resolvedAlert.historyRecords = resolvedAlert.historyRecords || []
-    resolvedAlert.historyRecords.unshift({
-      id: Date.now(),
-      time: new Date().toLocaleString(),
-      type: 'danger',
-      content: `综合研判:确认为真报警,处置说明:${judgmentRemark.value || '已安排人员现场排查'}`
-    })
-    ElNotification({
-      title: '报警确认',
-      message: `已确认真报警,工单已派发至${selectedAlert.value.pointName}所属维护单位`,
-      type: 'warning'
-    })
-    ElMessage.success('已确认真报警,处置流程已启动')
-  }
-  judgmentRemark.value = ''
-  if (realtimeAlerts.value.length > 0) {
-    selectedAlert.value = realtimeAlerts.value[0]
-  } else {
-    selectedAlert.value = null
+const resolveSelectedAlarm = async (kind) => {
+  const alert = selectedAlert.value
+  if (!alert?.id || alert.status === 'resolved' || resolving.value) return
+  const isFalseAlarm = kind === 'false'
+  try {
+    await ElMessageBox.confirm(
+      isFalseAlarm ? '确认将该报警标记为误报警吗?' : '确认该报警为真报警并标记为已处理吗?',
+      isFalseAlarm ? '排除误报警' : '报警确认',
+      { type: isFalseAlarm ? 'info' : 'warning', confirmButtonText: '确认', cancelButtonText: '取消' }
+    )
+  } catch {
+    return
   }
-}
 
-const confirmFalseAlarm = () => {
-  if (selectedAlert.value.status === 'resolved') return
-  const index = realtimeAlerts.value.findIndex(a => a.id === selectedAlert.value.id)
-  if (index !== -1) {
-    realtimeAlerts.value.splice(index, 1)
+  resolving.value = true
+  try {
+    const remark = judgmentRemark.value.trim()
+    await resolveGasAlarm(alert.id, remark || (isFalseAlarm ? '经核实为误报警' : '确认为真报警'))
     ElNotification({
-      title: '误报警排除',
-      message: `已标记为误报警,排除说明:${judgmentRemark.value || '经核实为传感器干扰导致'}`,
-      type: 'info'
+      title: isFalseAlarm ? '误报警排除' : '报警确认',
+      message: isFalseAlarm ? '报警已标记为误报警并从实时列表移除' : '真报警已确认并标记为已处理',
+      type: isFalseAlarm ? 'info' : 'warning'
     })
-    ElMessage.info('已排除误报警')
-  }
-  judgmentRemark.value = ''
-  if (realtimeAlerts.value.length > 0) {
-    selectedAlert.value = realtimeAlerts.value[0]
-  } else {
-    selectedAlert.value = null
+    ElMessage.success(isFalseAlarm ? '已排除误报警' : '已确认真报警')
+    await loadAlerts()
+  } catch (error) {
+    ElMessage.error(error?.message || (isFalseAlarm ? '排除误报警失败' : '确认报警失败'))
+  } finally {
+    resolving.value = false
+    judgmentRemark.value = ''
   }
 }
 
-const dispatchWorkOrder = () => {
-  ElMessage.success(`已向${selectedAlert.value.pointName}所属单位派发处置工单`)
+const confirmTrueAlarm = () => resolveSelectedAlarm('true')
+const confirmFalseAlarm = () => resolveSelectedAlarm('false')
+
+const dispatchWorkOrder = async () => {
+  const alert = selectedAlert.value
+  if (!alert?.id || alert.hasWorkOrder || alert.status === 'resolved' || dispatching.value) return
+  try {
+    await ElMessageBox.confirm(
+      `确认将“${alert.pointName}”的相邻地下空间可燃气体报警转为工单吗?`,
+      '派发工单',
+      { type: 'warning', confirmButtonText: '确认派发', cancelButtonText: '取消' }
+    )
+  } catch {
+    return
+  }
+
+  dispatching.value = true
+  try {
+    const res = await dispatchGasAlarm(alert.id, {
+      orderLevel: alert.level === 'critical' ? 0 : 1,
+      orderDesc: `${alert.pointName || '相邻地下空间'}可燃气体浓度报警,请现场核实处理`
+    })
+    alert.hasWorkOrder = true
+    alert.workOrderId = res.data || null
+    alert.workOrderStatusText = '待派单'
+    ElMessage.success(res.msg || '报警已转为待派单工单')
+    await loadAlerts()
+  } catch (error) {
+    ElMessage.error(error?.message || '工单派发失败')
+  } finally {
+    dispatching.value = false
+  }
 }
 
-const refreshData = () => {
-  ElMessage.success('数据已刷新')
+const refreshData = async () => {
+  const ok = await loadAlerts()
+  if (ok) ElMessage.success('数据已刷新')
 }
 
 const exportReport = () => {
   ElMessage.success('导出报警报表(演示)')
 }
 
-onMounted(() => {
+onMounted(async () => {
+  await loadAlerts()
   nextTick(() => renderChart())
 })
 
@@ -548,4 +608,4 @@ onBeforeUnmount(() => {
 
 .action-buttons { display: flex; gap: 12px; margin-top: 8px; }
 .history-section { margin-top: 20px; }
-</style>
+</style>

+ 152 - 83
src/views/subSystem/hidden/HazardList.vue

@@ -24,14 +24,10 @@
           </el-select>
         </el-form-item>
         <el-form-item label="关联管网">
-          <el-select v-model="searchForm.networkId" placeholder="请选择" clearable style="width:160px">
-            <el-option v-for="n in networkOptions" :key="n.networkId" :label="n.networkName" :value="n.networkId" />
-          </el-select>
+          <el-select-v2 v-model="searchForm.networkId" placeholder="请选择" clearable filterable style="width:160px" :options="networkSelectOptions" @focus="loadNetworkOptions" />
         </el-form-item>
         <el-form-item label="关联设备">
-          <el-select v-model="searchForm.equipmentId" placeholder="请选择" clearable style="width:160px">
-            <el-option v-for="e in equipmentOptions" :key="e.equipmentId" :label="e.equipmentName" :value="e.equipmentId" />
-          </el-select>
+          <el-select-v2 v-model="searchForm.equipmentId" placeholder="请选择" clearable filterable style="width:160px" :options="equipmentSelectOptions" @focus="loadEquipmentOptions" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleSearch">查询</el-button>
@@ -63,11 +59,12 @@
         </el-table-column>
         <el-table-column label="隐患图片" width="90">
           <template #default="{ row }">
-            <template v-if="parseImageUrls(row.hazardImages).length">
+            <template v-if="getRowImageUrls(row).length">
               <el-image
-                :src="parseImageUrls(row.hazardImages)[0]"
-                fit="cover"
-                :preview-src-list="parseImageUrls(row.hazardImages)"
+                :src="getRowImageUrls(row)[0]"
+                fit="contain"
+                lazy
+                :preview-src-list="getRowImageUrls(row)"
                 preview-teleported
                 hide-on-click-modal
                 class="table-image"
@@ -84,13 +81,10 @@
         </el-table-column>
         <el-table-column prop="reportUnit" label="上报单位" width="120" />
         <el-table-column prop="reportTime" label="上报时间" width="160" />
-        <el-table-column label="操作" width="350" fixed="right">
+        <el-table-column label="操作" width="220" 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="success" 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>
@@ -134,16 +128,12 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="关联管网">
-              <el-select v-model="formData.networkId" placeholder="请选择" clearable style="width: 100%">
-                <el-option v-for="n in networkOptions" :key="n.networkId" :label="n.networkName" :value="n.networkId" />
-              </el-select>
+              <el-select-v2 v-model="formData.networkId" placeholder="请选择" clearable filterable style="width: 100%" :options="networkSelectOptions" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="关联设备" prop="equipmentId">
-              <el-select v-model="formData.equipmentId" placeholder="请选择" clearable style="width: 100%">
-                <el-option v-for="e in equipmentOptions" :key="e.equipmentId" :label="e.equipmentName" :value="e.equipmentId" />
-              </el-select>
+              <el-select-v2 v-model="formData.equipmentId" placeholder="请选择" clearable filterable style="width: 100%" :options="equipmentSelectOptions" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -187,27 +177,29 @@
             </el-form-item>
           </el-col>
           <el-col :span="24">
-            <el-form-item label="隐患图片">
-              <el-upload
-                action="#"
-                :auto-upload="false"
-                :show-file-list="false"
-                :http-request="() => {}"
-                :on-change="handleImageChange"
-                accept="image/jpeg,image/png,image/jpg"
-                multiple
-              >
-                <el-button type="primary" plain :loading="imageUploading">
-                  {{ imageUploading ? '上传中...' : '选择图片' }}
-                </el-button>
-                <template #tip>
-                  <div class="el-upload__tip">支持 JPG/PNG 格式,单张不超过 5MB,可多选</div>
-                </template>
-              </el-upload>
-              <div v-if="imageList.length" class="image-list">
-                <div v-for="(img, index) in imageList" :key="index" class="image-item">
-                  <img :src="img" class="image-thumb" @click="previewImage(index)" />
-                  <el-icon class="image-remove" @click="removeImage(index)"><Close /></el-icon>
+            <el-form-item label="隐患图片" class="image-form-item">
+              <div class="image-upload-content">
+                <el-upload
+                  action="#"
+                  :auto-upload="false"
+                  :show-file-list="false"
+                  :http-request="() => {}"
+                  :on-change="handleImageChange"
+                  accept="image/jpeg,image/png,image/jpg"
+                  multiple
+                >
+                  <el-button type="primary" plain :loading="imageUploading">
+                    {{ imageUploading ? '上传中...' : '选择图片' }}
+                  </el-button>
+                  <template #tip>
+                    <div class="el-upload__tip">支持 JPG/PNG 格式,单张不超过 5MB,可多选</div>
+                  </template>
+                </el-upload>
+                <div v-if="imageList.length" class="image-list">
+                  <div v-for="(img, index) in imageList" :key="index" class="image-item">
+                    <img :src="img" class="image-thumb" @click="previewImage(index)" />
+                    <el-icon class="image-remove" @click="removeImage(index)"><Close /></el-icon>
+                  </div>
                 </div>
               </div>
             </el-form-item>
@@ -246,13 +238,14 @@
         <el-descriptions-item label="经度">{{ detailData.longitude || '-' }}</el-descriptions-item>
         <el-descriptions-item label="纬度">{{ detailData.latitude || '-' }}</el-descriptions-item>
         <el-descriptions-item label="隐患图片" :span="2">
-          <div v-if="parseImageUrls(detailData.hazardImages).length" class="detail-image-list">
+          <div v-if="detailImageUrls.length" class="detail-image-list">
             <el-image
-              v-for="(img, index) in parseImageUrls(detailData.hazardImages)"
+              v-for="(img, index) in detailImageUrls"
               :key="index"
               :src="img"
-              fit="cover"
-              :preview-src-list="parseImageUrls(detailData.hazardImages)"
+              fit="contain"
+              lazy
+              :preview-src-list="detailImageUrls"
               :initial-index="index"
               preview-teleported
               hide-on-click-modal
@@ -277,11 +270,11 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { computed, ref, reactive, shallowRef, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Close } from '@element-plus/icons-vue'
 import request from '@/utils/request'
-import { getHazardPage, getHazardById, addHazard, updateHazard, deleteHazard, submitHazard } from '@/api/pipeNetwork/hazard'
+import { getHazardPage, getHazardById, addHazard, updateHazard, deleteHazard } from '@/api/pipeNetwork/hazard'
 import { getDicts } from '@/api/system/dict/data'
 import { getPipeNetworkOptions, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
 
@@ -317,12 +310,21 @@ const hazardTypeDict = ref([])
 const hazardLevelDict = ref([])
 const hazardStatusDict = ref([])
 const hazardSourceDict = ref([])
-const networkOptions = ref([])
-const equipmentOptions = ref([])
+// 选项列表可能较大,使用浅层响应式并缓存请求,避免每次编辑都触发整棵数组深度更新。
+const networkOptions = shallowRef([])
+const equipmentOptions = shallowRef([])
+const networkNameCache = shallowRef(new Map())
+const equipmentNameCache = shallowRef(new Map())
+const rowImageCache = new WeakMap()
+const networkSelectOptions = computed(() => networkOptions.value.map(item => ({ value: item.networkId, label: item.networkName })))
+const equipmentSelectOptions = computed(() => equipmentOptions.value.map(item => ({ value: item.equipmentId, label: item.equipmentName })))
+let networkOptionsRequest = null
+let equipmentOptionsRequest = null
 
 // 详情相关
 const detailVisible = ref(false)
 const detailData = ref(null)
+const detailImageUrls = computed(() => parseImageUrls(detailData.value?.hazardImages))
 
 // 图片相关
 const imageList = ref([])
@@ -349,7 +351,46 @@ function statusTag(value) {
 /** 将存储的 hazardImages 字符串转为 URL 数组 */
 function parseImageUrls(hazardImages) {
   if (!hazardImages) return []
-  return hazardImages.split(',').map(s => s.trim()).filter(Boolean)
+  let values = hazardImages
+  if (typeof values === 'string') {
+    const text = values.trim()
+    if (text.startsWith('[') || text.startsWith('{')) {
+      try {
+        values = JSON.parse(text)
+      } catch (e) {
+        values = text.split(',')
+      }
+    } else {
+      values = text.split(/[,;\n]/)
+    }
+  }
+  if (!Array.isArray(values)) values = [values]
+  return values
+    .map(item => {
+      const source = typeof item === 'object' ? (item.url || item.fileName || item.name) : item
+      return normalizeImageUrl(source)
+    })
+    .filter(Boolean)
+}
+
+function getRowImageUrls(row) {
+  if (!row || typeof row !== 'object') return []
+  if (!rowImageCache.has(row)) rowImageCache.set(row, parseImageUrls(row.hazardImages))
+  return rowImageCache.get(row)
+}
+
+/** 兼容 MinIO 完整地址、upload 相对路径和历史仅文件名数据 */
+function normalizeImageUrl(value) {
+  const url = String(value || '').trim()
+  if (!url || /^(https?:|blob:|data:)/i.test(url)) return url
+  const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
+  if (minioBase) {
+    if (url.startsWith('/')) return `${minioBase}${url}`
+    if (url.startsWith('upload/')) return `${minioBase}/${url}`
+    if (!url.includes('/')) return `${minioBase}/upload/${url}`
+  }
+  const baseApi = String(import.meta.env.VITE_APP_BASE_API || '').trim().replace(/\/+$/, '')
+  return url.startsWith('/') ? `${baseApi}${url}` : `${baseApi}/${url}`
 }
 
 /** 图片选择后立即上传到 MinIO */
@@ -369,7 +410,8 @@ async function handleImageChange(file) {
       method: 'post',
       data: uploadFormData
     })
-    const url = uploadResponse.url || uploadResponse.fileName
+    const url = normalizeImageUrl(uploadResponse.url || uploadResponse.fileName)
+    if (!url) throw new Error('上传接口未返回图片地址')
     imageUrls.value.push(url)
     imageList.value.push(url)
   } catch (e) {
@@ -419,20 +461,36 @@ function handleReset() {
   handleSearch()
 }
 async function loadNetworkOptions() {
-  if (networkOptions.value.length > 0) return
-  try { const res = await getPipeNetworkOptions(); networkOptions.value = res.data || [] } catch (e) { console.error(e) }
+  if (Array.isArray(networkOptions.value) && networkOptions.value.length > 0) return
+  if (networkOptionsRequest) return networkOptionsRequest
+  networkOptionsRequest = getPipeNetworkOptions()
+    .then(res => {
+      networkOptions.value = Array.isArray(res.data) ? res.data : []
+      networkNameCache.value = new Map(networkOptions.value.map(item => [String(item.networkId), item.networkName]))
+      return networkOptions.value
+    })
+    .catch(e => { console.error(e); return [] })
+    .finally(() => { networkOptionsRequest = null })
+  return networkOptionsRequest
 }
 async function loadEquipmentOptions() {
-  if (equipmentOptions.value.length > 0) return
-  try { const res = await getEquipmentByTopLevelType('燃气'); equipmentOptions.value = res.data || [] } catch (e) { console.error(e) }
+  if (Array.isArray(equipmentOptions.value) && equipmentOptions.value.length > 0) return
+  if (equipmentOptionsRequest) return equipmentOptionsRequest
+  equipmentOptionsRequest = getEquipmentByTopLevelType('燃气')
+    .then(res => {
+      equipmentOptions.value = Array.isArray(res.data) ? res.data : []
+      equipmentNameCache.value = new Map(equipmentOptions.value.map(item => [String(item.equipmentId), item.equipmentName]))
+      return equipmentOptions.value
+    })
+    .catch(e => { console.error(e); return [] })
+    .finally(() => { equipmentOptionsRequest = null })
+  return equipmentOptionsRequest
 }
 function getNetworkName(id) {
-  const found = networkOptions.value.find(n => n.networkId === id)
-  return found ? found.networkName : id || '-'
+  return networkNameCache.value.get(String(id)) || id || '-'
 }
 function getEquipmentName(id) {
-  const found = equipmentOptions.value.find(e => e.equipmentId === id)
-  return found ? found.equipmentName : id || '-'
+  return equipmentNameCache.value.get(String(id)) || id || '-'
 }
 /** 自动生成隐患编号:YH-YYYYMMDDHHmmss */
 function generateHazardNo() {
@@ -452,13 +510,31 @@ function handleAdd() {
 }
 function handleEdit(row) {
   dialogTitle.value = '编辑'
-  Object.assign(formData, row)
+  // 只回填表单字段,避免把接口返回的分页/审计字段直接写入响应式表单。
+  // 直接 Object.assign(row) 在部分数据返回结构下会导致弹窗渲染持续触发更新。
+  const editableFields = [
+    'hazardId', 'hazardNo', 'hazardType', 'hazardLevel', 'hazardDesc',
+    'hazardSource', 'hazardStatus', 'reportUnit', 'reportTime',
+    'rectifyPlan', 'rectifyDeadline', 'rectifyPerson', 'networkId',
+    'equipmentId', 'longitude', 'latitude', 'hazardImages'
+  ]
+  editableFields.forEach(key => {
+    const value = row?.[key]
+    if (key === 'longitude' || key === 'latitude') {
+      formData[key] = value === null || value === undefined || value === '' ? null : Number(value)
+    } else if (key === 'hazardImages') {
+      formData[key] = parseImageUrls(value).join(',')
+    } else {
+      formData[key] = value ?? ''
+    }
+  })
   // 回显已有图片
-  const urls = parseImageUrls(row.hazardImages)
+  const urls = getRowImageUrls(row)
   imageUrls.value = [...urls]
   imageList.value = [...urls]
   dialogVisible.value = true
-  loadNetworkOptions(); loadEquipmentOptions()
+  // 选项加载不阻塞弹窗打开,且函数内部会避免重复请求。
+  void Promise.allSettled([loadNetworkOptions(), loadEquipmentOptions()])
 }
 async function handleSave() {
   if (!formRef.value) return
@@ -480,24 +556,6 @@ function handleDelete(row) {
     await deleteHazard(row.hazardId); ElMessage.success('删除成功'); loadData()
   }).catch(() => {})
 }
-/** 报送(同时自动生成工单) */
-async function handleSubmitHazard(row) {
-  ElMessageBox.confirm('确认报送该隐患?报送后将自动创建工单进入待派单状态。', '提示', { type: 'warning' }).then(async () => {
-    try {
-      const res = await submitHazard(row.hazardId)
-      if (res.code === 200) {
-        const msg = res.data ? '报送成功,已自动创建工单' : '报送成功'
-        ElMessage.success(msg)
-        loadData()
-      } else {
-        ElMessage.error(res.msg || '报送失败')
-      }
-    } catch (e) {
-      ElMessage.error(e?.response?.data?.msg || e?.message || '报送失败')
-    }
-  }).catch(() => {})
-}
-
 async function handleDetail(row) {
   try {
     const res = await getHazardById(row.hazardId)
@@ -510,7 +568,10 @@ async function handleDetail(row) {
   }
 }
 
-onMounted(async () => { await loadDicts(); loadData(); loadNetworkOptions(); loadEquipmentOptions() })
+onMounted(async () => {
+  await loadDicts()
+  void loadData()
+})
 </script>
 
 <style scoped>
@@ -532,6 +593,12 @@ onMounted(async () => { await loadDicts(); loadData(); loadNetworkOptions(); loa
   gap: 8px;
   margin-top: 8px;
 }
+.image-form-item :deep(.el-form-item__content) {
+  display: block;
+}
+.image-upload-content {
+  width: 100%;
+}
 .image-item {
   position: relative;
   width: 80px;
@@ -542,7 +609,8 @@ onMounted(async () => { await loadDicts(); loadData(); loadNetworkOptions(); loa
 .image-thumb {
   width: 80px;
   height: 80px;
-  object-fit: cover;
+  object-fit: contain;
+  background: #f5f7fa;
   border-radius: 4px;
   border: 1px solid #dcdfe6;
   cursor: pointer;
@@ -581,6 +649,7 @@ onMounted(async () => { await loadDicts(); loadData(); loadNetworkOptions(); loa
   height: 120px;
   border-radius: 6px;
   cursor: pointer;
-  object-fit: cover;
+  object-fit: contain;
+  background: #f5f7fa;
 }
 </style>

+ 578 - 0
src/views/subSystem/hidden/HazardOperationPage.vue

@@ -0,0 +1,578 @@
+<template>
+  <div class="workflow-page">
+    <el-card class="search-card" shadow="never">
+      <el-form :inline="true" :model="queryParams" class="search-form">
+        <el-form-item label="关键词">
+          <el-input
+            v-model="queryParams.keyword"
+            clearable
+            placeholder="隐患编号/描述/上报单位"
+            style="width: 220px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="隐患类型">
+          <el-select v-model="queryParams.hazardType" clearable placeholder="全部" style="width: 140px">
+            <el-option v-for="item in hazardTypeDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="隐患等级">
+          <el-select v-model="queryParams.hazardLevel" clearable placeholder="全部" style="width: 130px">
+            <el-option v-for="item in hazardLevelDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="隐患来源">
+          <el-select v-model="queryParams.hazardSource" clearable placeholder="全部" style="width: 150px">
+            <el-option v-for="item in hazardSourceDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="报送状态">
+          <el-select v-model="queryParams.isSubmitted" clearable placeholder="全部" style="width: 120px">
+            <el-option label="未报送" :value="0" />
+            <el-option label="已报送" :value="1" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="mode !== 'submission'" label="治理状态">
+          <el-select v-model="queryParams.hazardStatus" clearable placeholder="全部" style="width: 130px">
+            <el-option v-for="item in hazardStatusDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="mode === 'submission'" label="时间类型">
+          <el-select v-model="queryParams.timeType" style="width: 140px">
+            <el-option label="隐患上报时间" value="report" />
+            <el-option label="报送时间" value="submit" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            value-format="YYYY-MM-DD"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            style="width: 250px"
+          />
+        </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" shadow="never">
+      <el-table v-loading="loading" :data="tableData" border stripe>
+        <el-table-column prop="hazardNo" label="隐患编号" width="155" fixed="left" />
+        <el-table-column label="隐患类型" width="115">
+          <template #default="{ row }">{{ getDictLabel(hazardTypeDict, row.hazardType) }}</template>
+        </el-table-column>
+        <el-table-column label="隐患等级" width="105">
+          <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="220" show-overflow-tooltip />
+        <el-table-column label="隐患来源" width="125">
+          <template #default="{ row }">{{ getDictLabel(hazardSourceDict, row.hazardSource) || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="reportUnit" label="上报单位" width="150" show-overflow-tooltip />
+        <el-table-column prop="reportTime" label="上报时间" width="165" />
+
+        <template v-if="mode === 'submission'">
+          <el-table-column label="报送状态" width="100">
+            <template #default="{ row }">
+              <el-tag :type="isSubmitted(row) ? 'success' : 'info'">{{ isSubmitted(row) ? '已报送' : '未报送' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="submitTime" label="报送时间" width="165">
+            <template #default="{ row }">{{ row.submitTime || '-' }}</template>
+          </el-table-column>
+        </template>
+
+        <template v-else>
+          <el-table-column label="治理状态" width="110">
+            <template #default="{ row }">
+              <el-tag :type="statusTag(row.hazardStatus)">{{ getDictLabel(hazardStatusDict, row.hazardStatus) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="rectifyPerson" label="整改责任人" width="120">
+            <template #default="{ row }">{{ row.rectifyPerson || '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="rectifyDeadline" label="整改期限" width="120">
+            <template #default="{ row }">{{ row.rectifyDeadline || '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="rectifyFeedback" label="治理反馈" min-width="180" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.rectifyFeedback || '-' }}</template>
+          </el-table-column>
+        </template>
+
+        <el-table-column label="操作" :width="mode === 'submission' ? 190 : 180" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link @click="handleDetail(row)">详情</el-button>
+            <el-button
+              v-if="mode === 'submission'"
+              type="success"
+              link
+              :disabled="isSubmitted(row)"
+              @click="handleSubmit(row)"
+            >
+              {{ isSubmitted(row) ? '已报送' : '报送' }}
+            </el-button>
+            <el-button v-else type="warning" link @click="handleFeedback(row)">治理反馈</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="pageNum"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @current-change="loadData"
+        @size-change="handleSizeChange"
+      />
+    </el-card>
+
+    <el-drawer v-model="detailVisible" title="隐患详情" size="760px" direction="rtl">
+      <el-descriptions v-if="detailData" :column="2" border>
+        <el-descriptions-item label="隐患编号">{{ detailData.hazardNo }}</el-descriptions-item>
+        <el-descriptions-item label="隐患类型">{{ getDictLabel(hazardTypeDict, detailData.hazardType) }}</el-descriptions-item>
+        <el-descriptions-item label="隐患等级">
+          <el-tag :type="levelTag(detailData.hazardLevel)">{{ getDictLabel(hazardLevelDict, detailData.hazardLevel) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="隐患来源">{{ getDictLabel(hazardSourceDict, detailData.hazardSource) || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="治理状态">
+          <el-tag :type="statusTag(detailData.hazardStatus)">{{ getDictLabel(hazardStatusDict, detailData.hazardStatus) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="报送状态">
+          <el-tag :type="isSubmitted(detailData) ? 'success' : 'info'">{{ isSubmitted(detailData) ? '已报送' : '未报送' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="上报单位">{{ detailData.reportUnit || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上报时间">{{ detailData.reportTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="报送时间">{{ detailData.submitTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="核查人">{{ detailData.verifyPerson || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="核查时间">{{ detailData.verifyTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="整改责任人">{{ detailData.rectifyPerson || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="整改期限">{{ detailData.rectifyDeadline || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="隐患描述" :span="2">{{ detailData.hazardDesc || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="整改方案" :span="2">{{ detailData.rectifyPlan || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="治理反馈" :span="2">{{ detailData.rectifyFeedback || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="隐患图片" :span="2">
+          <div v-if="detailImageUrls.length" class="detail-images">
+            <el-image
+              v-for="(image, index) in detailImageUrls"
+              :key="image"
+              :src="image"
+              :preview-src-list="detailImageUrls"
+              :initial-index="index"
+              preview-teleported
+              fit="contain"
+              lazy
+            />
+          </div>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-drawer>
+
+    <el-dialog v-model="feedbackVisible" title="隐患治理反馈" width="720px" destroy-on-close>
+      <el-form ref="feedbackFormRef" :model="feedbackForm" :rules="feedbackRules" label-width="105px">
+        <el-alert
+          :title="`${feedbackForm.hazardNo || ''} ${feedbackForm.hazardDesc || ''}`"
+          type="info"
+          :closable="false"
+          class="feedback-alert"
+        />
+        <el-row :gutter="18">
+          <el-col :span="12">
+            <el-form-item label="核查人" prop="verifyPerson">
+              <el-input v-model="feedbackForm.verifyPerson" placeholder="请输入现场核查人" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="核查时间" prop="verifyTime">
+              <el-date-picker
+                v-model="feedbackForm.verifyTime"
+                type="datetime"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择核查时间"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="整改责任人" prop="rectifyPerson">
+              <el-input v-model="feedbackForm.rectifyPerson" placeholder="请输入整改责任人" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="整改期限" prop="rectifyDeadline">
+              <el-date-picker
+                v-model="feedbackForm.rectifyDeadline"
+                type="date"
+                value-format="YYYY-MM-DD"
+                placeholder="请选择整改期限"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="治理状态" prop="hazardStatus">
+              <el-select v-model="feedbackForm.hazardStatus" style="width: 100%">
+                <el-option v-for="item in hazardStatusDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="整改方案" prop="rectifyPlan">
+              <el-input v-model="feedbackForm.rectifyPlan" type="textarea" :rows="3" placeholder="请输入现场核查后的整改方案" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="治理反馈" prop="rectifyFeedback">
+              <el-input v-model="feedbackForm.rectifyFeedback" type="textarea" :rows="4" placeholder="请输入治理过程、处理结果及现场复核情况" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="备注">
+              <el-input v-model="feedbackForm.remark" type="textarea" :rows="2" placeholder="可补充填写其他说明" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <template #footer>
+        <el-button @click="feedbackVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveFeedback">保存反馈</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getDicts } from '@/api/system/dict/data'
+import { getHazardById, getHazardPage, submitHazard, updateHazard } from '@/api/pipeNetwork/hazard'
+
+const props = defineProps({
+  mode: {
+    type: String,
+    required: true,
+    validator: value => ['submission', 'feedback'].includes(value)
+  }
+})
+
+const loading = ref(false)
+const saving = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const dateRange = ref([])
+const queryParams = reactive({
+  keyword: '',
+  hazardType: '',
+  hazardLevel: '',
+  hazardSource: '',
+  hazardStatus: '',
+  // 治理反馈默认展示全部隐患,避免历史数据未补录报送状态时出现空表。
+  // 需要只看已报送数据时,可通过上方筛选项选择“已报送”。
+  isSubmitted: null,
+  timeType: 'report'
+})
+
+const hazardTypeDict = ref([])
+const hazardLevelDict = ref([])
+const hazardStatusDict = ref([])
+const hazardSourceDict = ref([])
+const detailVisible = ref(false)
+const detailData = ref(null)
+const detailImageUrls = computed(() => parseImageUrls(detailData.value?.hazardImages))
+const feedbackVisible = ref(false)
+const feedbackFormRef = ref(null)
+const feedbackRecord = ref(null)
+
+const feedbackForm = reactive({
+  hazardNo: '',
+  hazardDesc: '',
+  verifyPerson: '',
+  verifyTime: '',
+  rectifyPerson: '',
+  rectifyDeadline: '',
+  hazardStatus: '',
+  rectifyPlan: '',
+  rectifyFeedback: '',
+  remark: ''
+})
+
+const feedbackRules = {
+  verifyPerson: [{ required: true, message: '请输入现场核查人', trigger: 'blur' }],
+  verifyTime: [{ required: true, message: '请选择核查时间', trigger: 'change' }],
+  rectifyPerson: [{ required: true, message: '请输入整改责任人', trigger: 'blur' }],
+  rectifyDeadline: [{ required: true, message: '请选择整改期限', trigger: 'change' }],
+  hazardStatus: [{ required: true, message: '请选择治理状态', trigger: 'change' }],
+  rectifyPlan: [{ required: true, message: '请输入整改方案', trigger: 'blur' }],
+  rectifyFeedback: [{ required: true, message: '请输入治理反馈', trigger: 'blur' }]
+}
+
+function getDictLabel(dict, value) {
+  const item = dict.find(entry => String(entry.dictValue) === String(value))
+  return item ? item.dictLabel : value
+}
+
+function isSubmitted(row) {
+  return Number(row?.isSubmitted) === 1
+}
+
+function levelTag(value) {
+  const label = String(getDictLabel(hazardLevelDict.value, value) || '')
+  if (label.includes('重大') || String(value) === '0') return 'danger'
+  if (label.includes('较大') || String(value) === '1') return 'warning'
+  if (label.includes('一般') || String(value) === '2') return 'primary'
+  return 'info'
+}
+
+function statusTag(value) {
+  const label = String(getDictLabel(hazardStatusDict.value, value) || '')
+  if (label.includes('已整改') || label.includes('闭环')) return 'success'
+  if (label.includes('整改中') || label.includes('治理中')) return 'warning'
+  return 'info'
+}
+
+function parseImageUrls(value) {
+  if (!value) return []
+  let values = value
+  if (typeof values === 'string') {
+    const text = values.trim()
+    if (text.startsWith('[') || text.startsWith('{')) {
+      try {
+        values = JSON.parse(text)
+      } catch (error) {
+        values = text.split(/[,;\n]/)
+      }
+    } else {
+      values = text.split(/[,;\n]/)
+    }
+  }
+  if (!Array.isArray(values)) values = [values]
+  return values.map(item => {
+    const source = typeof item === 'object' ? (item.url || item.fileName || item.name) : item
+    return normalizeImageUrl(source)
+  }).filter(Boolean)
+}
+
+function normalizeImageUrl(value) {
+  const url = String(value || '').trim()
+  if (!url || /^(https?:|blob:|data:)/i.test(url)) return url
+  const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
+  if (minioBase) {
+    if (url.startsWith('/')) return `${minioBase}${url}`
+    if (url.startsWith('upload/')) return `${minioBase}/${url}`
+    if (!url.includes('/')) return `${minioBase}/upload/${url}`
+  }
+  const baseApi = String(import.meta.env.VITE_APP_BASE_API || '').trim().replace(/\/+$/, '')
+  return url.startsWith('/') ? `${baseApi}${url}` : `${baseApi}/${url}`
+}
+
+function buildQuery() {
+  const params = {}
+  Object.entries(queryParams).forEach(([key, value]) => {
+    if (value !== '' && value !== null && value !== undefined) params[key] = value
+  })
+  if (dateRange.value?.length === 2) {
+    params.startDate = dateRange.value[0]
+    params.endDate = dateRange.value[1]
+  }
+  if (props.mode !== 'submission') {
+    params.timeType = 'report'
+  }
+  return params
+}
+
+async function loadDicts() {
+  try {
+    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 || []
+  } catch (error) {
+    ElMessage.error(error?.message || '隐患字典加载失败')
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const response = await getHazardPage(pageNum.value, pageSize.value, buildQuery())
+    tableData.value = response.data?.records || []
+    total.value = Number(response.data?.total || 0)
+  } catch (error) {
+    ElMessage.error(error?.response?.data?.msg || error?.message || '隐患数据加载失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pageNum.value = 1
+  loadData()
+}
+
+function handleReset() {
+  Object.assign(queryParams, {
+    keyword: '',
+    hazardType: '',
+    hazardLevel: '',
+    hazardSource: '',
+    hazardStatus: '',
+    isSubmitted: null,
+    timeType: 'report'
+  })
+  dateRange.value = []
+  handleSearch()
+}
+
+function handleSizeChange() {
+  pageNum.value = 1
+  loadData()
+}
+
+async function fetchDetail(row) {
+  try {
+    const response = await getHazardById(row.hazardId)
+    return response.data || row
+  } catch (error) {
+    return row
+  }
+}
+
+async function handleDetail(row) {
+  detailData.value = await fetchDetail(row)
+  detailVisible.value = true
+}
+
+async function handleSubmit(row) {
+  try {
+    await ElMessageBox.confirm(
+      `确认将隐患“${row.hazardNo}”报送到上级主管单位?报送后将按现有逻辑自动创建工单。`,
+      '隐患报送确认',
+      { type: 'warning', confirmButtonText: '确认报送', cancelButtonText: '取消' }
+    )
+    const response = await submitHazard(row.hazardId)
+    ElMessage.success(response.data ? '报送成功,已自动创建工单' : '报送成功')
+    loadData()
+  } catch (error) {
+    if (error === 'cancel' || error === 'close') return
+    ElMessage.error(error?.response?.data?.msg || error?.message || '报送失败')
+  }
+}
+
+function formatNow() {
+  const now = new Date()
+  const pad = value => String(value).padStart(2, '0')
+  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+}
+
+async function handleFeedback(row) {
+  const record = await fetchDetail(row)
+  feedbackRecord.value = record
+  Object.assign(feedbackForm, {
+    hazardNo: record.hazardNo || '',
+    hazardDesc: record.hazardDesc || '',
+    verifyPerson: record.verifyPerson || '',
+    verifyTime: record.verifyTime || formatNow(),
+    rectifyPerson: record.rectifyPerson || '',
+    rectifyDeadline: record.rectifyDeadline || '',
+    hazardStatus: record.hazardStatus || '',
+    rectifyPlan: record.rectifyPlan || '',
+    rectifyFeedback: record.rectifyFeedback || '',
+    remark: record.remark || ''
+  })
+  feedbackVisible.value = true
+}
+
+async function saveFeedback() {
+  if (!feedbackFormRef.value || !feedbackRecord.value) return
+  try {
+    await feedbackFormRef.value.validate()
+    saving.value = true
+    await updateHazard({
+      ...feedbackRecord.value,
+      verifyPerson: feedbackForm.verifyPerson,
+      verifyTime: feedbackForm.verifyTime,
+      rectifyPerson: feedbackForm.rectifyPerson,
+      rectifyDeadline: feedbackForm.rectifyDeadline,
+      hazardStatus: feedbackForm.hazardStatus,
+      rectifyPlan: feedbackForm.rectifyPlan,
+      rectifyFeedback: feedbackForm.rectifyFeedback,
+      remark: feedbackForm.remark
+    })
+    ElMessage.success('治理反馈保存成功')
+    feedbackVisible.value = false
+    loadData()
+  } catch (error) {
+    if (error?.fields) return
+    ElMessage.error(error?.response?.data?.msg || error?.message || '治理反馈保存失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadDicts()
+  loadData()
+})
+</script>
+
+<style scoped>
+.workflow-page {
+  min-height: 100%;
+  padding: 16px;
+  background: #f5f7fa;
+}
+
+.search-card {
+  margin-bottom: 14px;
+  border: 1px solid #e1e8ef;
+}
+
+.search-form :deep(.el-form-item) {
+  margin-bottom: 10px;
+}
+
+.table-card {
+  border: 1px solid #e1e8ef;
+}
+
+.pagination {
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+
+.detail-images {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.detail-images .el-image {
+  width: 110px;
+  height: 110px;
+  border-radius: 6px;
+}
+
+.feedback-alert {
+  margin-bottom: 20px;
+}
+
+</style>

+ 7 - 0
src/views/subSystem/hidden/yhbs.vue

@@ -0,0 +1,7 @@
+<template>
+  <HazardOperationPage mode="submission" />
+</template>
+
+<script setup>
+import HazardOperationPage from './HazardOperationPage.vue'
+</script>

+ 20 - 0
src/views/subSystem/hidden/yhpc.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="inspection-page">
+    <HazardList />
+  </div>
+</template>
+
+<script setup>
+import HazardList from './HazardList.vue'
+</script>
+
+<style scoped>
+.inspection-page {
+  min-height: 100%;
+  background: #f5f7fa;
+}
+
+:deep(.hazard-container) {
+  padding-top: 12px;
+}
+</style>

+ 7 - 0
src/views/subSystem/hidden/yhzlfk.vue

@@ -0,0 +1,7 @@
+<template>
+  <HazardOperationPage mode="feedback" />
+</template>
+
+<script setup>
+import HazardOperationPage from './HazardOperationPage.vue'
+</script>