Переглянути джерело

feat: 隐患模块改造、燃气管网GIS一张图及场站视频四宫格

null 17 годин тому
батько
коміт
3838ee285a

+ 10 - 3
src/api/pipeNetwork/hazard.js

@@ -19,6 +19,10 @@ export function deleteHazard(id) {
 export function submitHazard(hazardId) {
   return request({ url: '/api/hazard/submit/' + hazardId, method: 'put' })
 }
+// 隐患治理反馈:仅录入反馈内容,反馈后不允许二次修改
+export function feedbackHazard(hazardId, rectifyFeedback) {
+  return request({ url: '/api/hazard/feedback/' + hazardId, method: 'put', data: { rectifyFeedback } })
+}
 export function getHazardMapPoints() {
   return request({ url: '/api/hazard/map-points', method: 'get' })
 }
@@ -34,15 +38,18 @@ export function getHazardStatisticsOverview(params) {
 export function getHazardStatisticsByLevel(params) {
   return request({ url: '/api/hazard/statistics/level', method: 'get', params })
 }
-export function getHazardStatisticsByRegion(params) {
-  return request({ url: '/api/hazard/statistics/region', method: 'get', params })
-}
 export function getHazardStatisticsTypeTimeline(params) {
   return request({ url: '/api/hazard/statistics/type-timeline', method: 'get', params })
 }
 export function getHazardStatisticsStatusTimeline(params) {
   return request({ url: '/api/hazard/statistics/status-timeline', method: 'get', params })
 }
+export function getHazardStatisticsRectifyPie(params) {
+  return request({ url: '/api/hazard/statistics/rectify-pie', method: 'get', params })
+}
+export function getHazardStatisticsDistrictPie(params) {
+  return request({ url: '/api/hazard/statistics/district-pie', method: 'get', params })
+}
 
 // ============ 监测预警 ============
 export function getAlarmPage(pageNum, pageSize, params) {

+ 129 - 3
src/views/subSystem/basic/PipeNetwork.vue

@@ -1,5 +1,32 @@
 <template>
   <div class="app-container">
+    <!-- 燃气GIS一张图:自「燃气GIS图」页面复制至本页上半部分,数据同源 pipe_network_base(network_type=0) -->
+    <el-card shadow="never" class="gis-card">
+      <template #header>
+        <div class="gis-card-header">
+          <div class="gis-title">
+            <el-icon><MapLocation /></el-icon>
+            <span>燃气管网GIS一张图</span>
+            <span class="gis-stats">管线 {{ pipelineCount }} 段 / 总长度 {{ pipelineTotalLength }} km</span>
+          </div>
+          <el-button size="small" type="primary" plain :icon="Refresh" :loading="gisLoading" @click="loadPipelines">刷新地图</el-button>
+        </div>
+      </template>
+      <div class="gis-map-wrapper">
+        <GisMapCanvas ref="mapRef" :lines="visibleLines" :center="mapCenter" :zoom="14" :show-marker-name="false"
+          :enable-clustering="false" @line-click="showLineDetail" @error="gisError = $event" />
+        <div v-if="!gisLoading && !visibleLines.length" class="empty-map">暂无带起止坐标的燃气管线数据</div>
+        <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="fitBounds">全图</el-button>
+          </el-button-group>
+        </div>
+        <el-alert v-if="gisError" class="gis-error-alert" :title="gisError" type="warning" :closable="false" show-icon />
+      </div>
+    </el-card>
+
     <!-- 查询条件 -->
     <el-form ref="queryRef" :model="searchForm" :inline="true" v-show="showSearch" label-width="80px">
       <el-form-item label="设施名称" prop="networkName">
@@ -108,18 +135,107 @@
         </div>
       </template>
     </el-drawer>
+    <!-- GIS管线详情(点击地图管线弹出,与列表详情抽屉独立) -->
+    <el-drawer v-model="lineDrawerVisible" :title="`管线详情 - ${currentLine?.name || ''}`" size="420px" direction="rtl" append-to-body>
+      <el-descriptions v-if="currentLine" :column="1" border size="small">
+        <el-descriptions-item label="设施名称">{{ currentLine.name || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="设施编号">{{ currentLine.code || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="设施类型">燃气设施(0)</el-descriptions-item>
+        <el-descriptions-item label="管网等级">{{ currentLine.level || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="管径">{{ currentLine.diameter || '—' }} mm</el-descriptions-item>
+        <el-descriptions-item label="材质">{{ currentLine.material || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="长度">{{ currentLine.length || '—' }} m</el-descriptions-item>
+        <el-descriptions-item label="位置描述">{{ currentLine.location || '—' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-drawer>
   </div>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
-import { getPipeNetworkPage, exportPipeNetwork } from '@/api/pipeNetwork/basic'
+import { MapLocation, Refresh, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
+import { getPipeNetworkPage, exportPipeNetwork, getGisPipeLines } from '@/api/pipeNetwork/basic'
 import { getDicts } from '@/api/system/dict/data'
 import { saveAs } from 'file-saver'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
 
 defineOptions({ name: 'GasPipeNetwork' })
 
+/* ============ 上半部分:燃气GIS一张图(复制自「燃气GIS图」页面 gasPipelintNet.vue) ============ */
+const mapRef = ref(null)
+const gisLoading = ref(false)
+const gisError = ref('')
+const visibleLayers = ref(['highPressure', 'mediumPressure', 'lowPressure', 'other'])
+const pipelines = ref([])
+const currentLine = ref(null)
+const lineDrawerVisible = 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,
+    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
+  }
+}
+
+async function loadPipelines() {
+  gisLoading.value = true
+  gisError.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
+    fitBounds()
+  } catch (error) {
+    pipelines.value = []
+    gisError.value = '燃气管网数据加载失败,请检查服务或网络。'
+    ElMessage.error(gisError.value)
+  } finally {
+    gisLoading.value = false
+  }
+}
+
+const visibleLines = computed(() => pipelines.value.filter(item => visibleLayers.value.includes(item.layer)))
+const pipelineCount = computed(() => pipelines.value.length)
+const pipelineTotalLength = computed(() => (pipelines.value.reduce((sum, item) => sum + (Number(item.length) || 0), 0) / 1000).toFixed(2))
+
+function showLineDetail(line) { currentLine.value = line; lineDrawerVisible.value = true }
+function fitBounds() { mapRef.value?.fitBounds?.() }
+function zoomIn() { mapRef.value?.zoomIn?.() }
+function zoomOut() { mapRef.value?.zoomOut?.() }
+
+/* ============ 下半部分:管网数据列表(原有功能保持不变) ============ */
 const loading = ref(false)
 const showSearch = ref(true)
 const tableData = ref([])
@@ -229,10 +345,20 @@ function handleExport() {
   }).then(() => doExport(networkIds)).catch(() => {})
 }
 
-onMounted(async () => { await loadDicts(); loadData() })
+onMounted(async () => { loadPipelines(); await loadDicts(); loadData() })
 </script>
 
 <style scoped>
+/* ============ 上半部分:GIS 一张图。浮动面板层级遵守 850~950,不盖缩放控件与抽屉 ============ */
+.gis-card { margin-bottom: 12px; }
+.gis-card-header { display: flex; justify-content: space-between; align-items: center; }
+.gis-title { display: flex; align-items: center; gap: 8px; font-weight: 600; color: #1f2d3d; font-size: 15px; }
+.gis-stats { font-weight: normal; font-size: 13px; color: #909399; margin-left: 8px; }
+.gis-map-wrapper { position: relative; height: 46vh; min-height: 360px; }
+.map-toolbar { position: absolute; top: 12px; right: 12px; z-index: 900; }
+.empty-map { position: absolute; inset: 0; z-index: 900; display: grid; place-items: center; color: #909399; background: rgba(255, 255, 255, 0.6); pointer-events: none; }
+.gis-error-alert { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); z-index: 920; width: 420px; }
+
 .dialog-footer {
   text-align: right;
 }

+ 120 - 212
src/views/subSystem/gas/gasOperamon/czspjk.vue

@@ -63,7 +63,7 @@
         </div>
       </div>
 
-      <!-- 右侧视频监控区 -->
+      <!-- 右侧视频监控区:四宫格窗口 -->
       <div class="video-panel">
         <div class="video-header" v-if="selectedStation">
           <div class="video-title">
@@ -76,42 +76,43 @@
             </el-tag>
           </div>
           <div class="video-actions">
-            <el-button size="small" :icon="Refresh" @click="refreshVideo">刷新</el-button>
-            <el-button size="small" :icon="FullScreen" @click="toggleVideoFullscreen">全屏</el-button>
+            <el-button size="small" :icon="Refresh" @click="refreshData">刷新</el-button>
+            <el-button size="small" :icon="FullScreen" @click="toggleFullscreen">全屏</el-button>
             <el-button size="small" :icon="Download" @click="captureSnapshot">抓拍</el-button>
           </div>
         </div>
 
-        <!-- 视频播放区域 -->
+        <!-- 四宫格视频窗口区域 -->
         <div class="video-container" v-if="selectedStation">
-          <div class="main-video" ref="videoContainer">
-            <video
-                ref="videoPlayer"
-                :src="selectedStation.videoUrl"
-                autoplay
-                muted
-                loop
-                controls
-                class="video-player"
-                v-if="selectedStation.videoStatus === 'online'"
-            ></video>
-            <div class="video-placeholder" v-else>
-              <el-icon><VideoCameraFilled /></el-icon>
-              <span>视频信号离线,请检查设备连接</span>
-            </div>
-            <!-- 监测数据叠加层 -->
-            <div class="monitor-overlay" v-if="selectedStation">
-              <div class="overlay-item methane" :class="{ warning: selectedStation.methane > selectedStation.methaneThreshold }">
-                <span class="label">甲烷浓度</span>
-                <span class="value">{{ selectedStation.methane }} ppm</span>
-              </div>
-              <div class="overlay-item pressure">
-                <span class="label">进口压力</span>
-                <span class="value">{{ selectedStation.inPressure }} kPa</span>
+          <div class="video-grid">
+            <div
+                v-for="(win, index) in cameraWindows"
+                :key="index"
+                class="camera-window"
+                :class="{ offline: !win.online }"
+            >
+              <div class="window-header">
+                <span class="cam-name">
+                  <el-icon><Camera /></el-icon>
+                  摄像头 {{ index + 1 }} · {{ win.name }}
+                </span>
+                <span class="cam-status" :class="win.empty ? 'none' : win.online ? 'on' : 'off'">
+                  {{ win.empty ? '未接入' : win.online ? '在线' : '离线' }}
+                </span>
               </div>
-              <div class="overlay-item pressure">
-                <span class="label">出口压力</span>
-                <span class="value">{{ selectedStation.outPressure }} kPa</span>
+              <div class="window-body">
+                <template v-if="win.empty">
+                  <el-icon><VideoCameraFilled /></el-icon>
+                  <span>暂无监控点位</span>
+                </template>
+                <template v-else-if="win.online">
+                  <el-icon><VideoCamera /></el-icon>
+                  <span>实时视频流等待接入</span>
+                </template>
+                <template v-else>
+                  <el-icon><VideoCameraFilled /></el-icon>
+                  <span>视频信号离线,请检查设备连接</span>
+                </template>
               </div>
             </div>
           </div>
@@ -147,27 +148,6 @@
               </div>
             </div>
           </div>
-
-          <!-- 监控点位列表(多摄像头) -->
-          <div class="camera-list" v-if="selectedStation.cameras?.length">
-            <div class="camera-header">
-              <span><el-icon><Camera /></el-icon> 监控点位 ({{ selectedStation.cameras.length }})</span>
-            </div>
-            <div class="camera-grid">
-              <div
-                  v-for="camera in selectedStation.cameras"
-                  :key="camera.id"
-                  class="camera-item"
-                  :class="{ active: currentCamera?.id === camera.id }"
-                  @click="switchCamera(camera)"
-              >
-                <div class="camera-preview">
-                  <el-icon><VideoCamera /></el-icon>
-                </div>
-                <div class="camera-name">{{ camera.name }}</div>
-              </div>
-            </div>
-          </div>
         </div>
 
         <!-- 未选中状态 -->
@@ -197,108 +177,87 @@
         </div>
       </div>
     </el-drawer>
-
-    <!-- 全屏视频对话框 -->
-    <el-drawer v-model="fullscreenVideoVisible" title="全屏监控" size="100%" :show-close="true" class="detail-drawer" direction="rtl">
-      <video
-          ref="fullscreenVideo"
-          :src="selectedStation?.videoUrl"
-          autoplay
-          muted
-          loop
-          controls
-          style="width: 100%; height: 100%; object-fit: contain;"
-          v-if="selectedStation?.videoStatus === 'online'"
-      ></video>
-      <div v-else class="fullscreen-placeholder">
-        <el-icon><VideoCameraFilled /></el-icon>
-        <span>视频信号离线</span>
-      </div>
-    </el-drawer>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { VideoCamera, VideoCameraFilled, Search, Refresh, FullScreen, Download, WindPower, Connection,  Camera } from '@element-plus/icons-vue'
+import { ref, computed, watch } from 'vue'
+import { VideoCamera, VideoCameraFilled, Search, Refresh, FullScreen, Download, WindPower, Connection, Camera } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 
-// 模拟视频流地址
-const mockVideoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'
-
 // 沅陵县燃气场站数据
 const mockStations = ref([
   {
     id: 1, name: '沅陵天然气门站', address: '沅陵县太常片区辰州北路888号',
     methane: 28, methaneThreshold: 20, inPressure: 385, outPressure: 235, temperature: 26.5,
-    alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: true, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '大门出入口', url: mockVideoUrl },
-      { id: 2, name: '工艺装置区', url: mockVideoUrl },
-      { id: 3, name: '调压计量区', url: mockVideoUrl },
-      { id: 4, name: '消防通道', url: mockVideoUrl }
+      { id: 1, name: '大门出入口' },
+      { id: 2, name: '工艺装置区' },
+      { id: 3, name: '调压计量区' },
+      { id: 4, name: '消防通道' }
     ]
   },
   {
     id: 2, name: '太常片区调压站', address: '沅陵县太常片区迎宾西路200号',
     methane: 5, methaneThreshold: 20, inPressure: 350, outPressure: 210, temperature: 25.8,
-    alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: false, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '工艺区', url: mockVideoUrl },
-      { id: 2, name: '控制室', url: mockVideoUrl }
+      { id: 1, name: '工艺区' },
+      { id: 2, name: '控制室' }
     ]
   },
   {
     id: 3, name: '沅陵城南CNG储配站', address: '沅陵县太常片区建设南路168号',
     methane: 12, methaneThreshold: 20, inPressure: 420, outPressure: 280, temperature: 24.5,
-    alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: false, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '场站全景', url: mockVideoUrl },
-      { id: 2, name: '储气罐区', url: mockVideoUrl },
-      { id: 3, name: '卸气区', url: mockVideoUrl },
-      { id: 4, name: '消防泵房', url: mockVideoUrl }
+      { id: 1, name: '场站全景' },
+      { id: 2, name: '储气罐区' },
+      { id: 3, name: '卸气区' },
+      { id: 4, name: '消防泵房' }
     ]
   },
   {
     id: 4, name: '沅陵工业园区调压站', address: '沅陵县太常片区工业大道500号',
     methane: 3, methaneThreshold: 20, inPressure: 310, outPressure: 185, temperature: 27.2,
-    alertStatus: false, videoStatus: 'offline', videoUrl: '',
+    alertStatus: false, videoStatus: 'offline',
     cameras: []
   },
   {
     id: 5, name: '沅陵城北供气站', address: '沅陵县太常片区天宁北路88号',
     methane: 18, methaneThreshold: 20, inPressure: 340, outPressure: 205, temperature: 25.0,
-    alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: false, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '主入口', url: mockVideoUrl },
-      { id: 2, name: '调压区', url: mockVideoUrl },
-      { id: 3, name: '值班室', url: mockVideoUrl }
+      { id: 1, name: '主入口' },
+      { id: 2, name: '调压区' },
+      { id: 3, name: '值班室' }
     ]
   },
   {
     id: 6, name: '沅陵城东门站', address: '沅陵县太常片区辰州东路666号',
     methane: 25, methaneThreshold: 20, inPressure: 370, outPressure: 230, temperature: 26.0,
-    alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: true, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '大门出入口', url: mockVideoUrl },
-      { id: 2, name: '过滤调压区', url: mockVideoUrl },
-      { id: 3, name: '加臭装置区', url: mockVideoUrl },
-      { id: 4, name: '周边环境', url: mockVideoUrl }
+      { id: 1, name: '大门出入口' },
+      { id: 2, name: '过滤调压区' },
+      { id: 3, name: '加臭装置区' },
+      { id: 4, name: '周边环境' }
     ]
   },
   {
     id: 7, name: '凉水井镇调压站', address: '沅陵县凉水井镇集镇路18号',
     methane: 1, methaneThreshold: 20, inPressure: 290, outPressure: 175, temperature: 25.3,
-    alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
+    alertStatus: false, videoStatus: 'online',
     cameras: [
-      { id: 1, name: '场站入口', url: mockVideoUrl },
-      { id: 2, name: '工艺区', url: mockVideoUrl }
+      { id: 1, name: '场站入口' },
+      { id: 2, name: '工艺区' }
     ]
   },
   {
     id: 8, name: '筲箕湾镇供气站', address: '沅陵县筲箕湾镇车站路32号',
     methane: 6, methaneThreshold: 20, inPressure: 275, outPressure: 165, temperature: 25.7,
-    alertStatus: false, videoStatus: 'offline', videoUrl: '',
+    alertStatus: false, videoStatus: 'offline',
     cameras: []
   }
 ])
@@ -311,9 +270,7 @@ const alertCount = computed(() => mockStations.value.filter(s => s.alertStatus).
 // 筛选
 const searchKeyword = ref('')
 const selectedStation = ref(mockStations.value[0])
-const currentCamera = ref(null)
 const alertDialogVisible = ref(false)
-const fullscreenVideoVisible = ref(false)
 const alertTime = ref('')
 
 const filteredStations = computed(() => {
@@ -325,15 +282,21 @@ const filteredStations = computed(() => {
   return list
 })
 
-// 视频播放器引用
-const videoPlayer = ref(null)
-const fullscreenVideo = ref(null)
-const videoContainer = ref(null)
+// 四宫格窗口:取场站前 4 个监控点位,不足补空位
+const cameraWindows = computed(() => {
+  const station = selectedStation.value
+  const cams = station?.cameras || []
+  const stationOnline = station?.videoStatus === 'online'
+  const list = cams.slice(0, 4).map(c => ({ name: c.name, online: stationOnline, empty: false }))
+  while (list.length < 4) {
+    list.push({ name: '暂无点位', online: false, empty: true })
+  }
+  return list
+})
 
 // 选择场站
 const selectStation = (station) => {
   selectedStation.value = station
-  currentCamera.value = station.cameras?.[0] || null
   // 检查是否有告警,自动弹出联动弹窗
   if (station.alertStatus && station.methane > station.methaneThreshold) {
     alertTime.value = new Date().toLocaleString()
@@ -341,31 +304,6 @@ const selectStation = (station) => {
   }
 }
 
-// 切换摄像头
-const switchCamera = (camera) => {
-  currentCamera.value = camera
-  if (videoPlayer.value) {
-    videoPlayer.value.src = camera.url
-    videoPlayer.value.load()
-    videoPlayer.value.play()
-  }
-  ElMessage.success(`切换到摄像头: ${camera.name}`)
-}
-
-// 刷新视频
-const refreshVideo = () => {
-  if (videoPlayer.value) {
-    videoPlayer.value.load()
-    videoPlayer.value.play()
-    ElMessage.success('视频已刷新')
-  }
-}
-
-// 全屏视频
-const toggleVideoFullscreen = () => {
-  fullscreenVideoVisible.value = true
-}
-
 // 页面全屏
 const toggleFullscreen = () => {
   const element = document.documentElement
@@ -384,7 +322,7 @@ const captureSnapshot = () => {
 // 查看告警现场视频
 const viewAlertVideo = () => {
   alertDialogVisible.value = false
-  toggleVideoFullscreen()
+  ElMessage.info('现场实时视频暂未接入,请通过监控点位窗口查看')
 }
 
 // 查看监测详情
@@ -404,15 +342,6 @@ watch(() => selectedStation.value?.alertStatus, (newVal) => {
     alertDialogVisible.value = true
   }
 })
-
-onMounted(() => {
-  // 模拟自动播放策略处理
-  nextTick(() => {
-    if (videoPlayer.value) {
-      videoPlayer.value.play().catch(e => console.log('自动播放被阻止,用户点击后播放'))
-    }
-  })
-})
 </script>
 
 <style scoped>
@@ -518,51 +447,66 @@ onMounted(() => {
   padding: 16px;
   overflow-y: auto;
 }
-.main-video {
-  position: relative;
-  background: #000;
-  border-radius: 12px;
+
+/* 四宫格视频窗口 */
+.video-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  grid-template-rows: repeat(2, 1fr);
+  gap: 12px;
+  margin-bottom: 16px;
+}
+.camera-window {
+  background: #1a1a2e;
+  border-radius: 10px;
   overflow: hidden;
   aspect-ratio: 16 / 9;
-  margin-bottom: 16px;
+  display: flex;
+  flex-direction: column;
+}
+.window-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 12px;
+  background: rgba(0, 0, 0, 0.55);
+  flex-shrink: 0;
 }
-.video-player {
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
+.cam-name {
+  color: #fff;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
-.video-placeholder {
-  width: 100%;
-  height: 100%;
+.cam-status {
+  font-size: 11px;
+  padding: 1px 8px;
+  border-radius: 10px;
+  flex-shrink: 0;
+}
+.cam-status.on { color: #67c23a; background: rgba(103, 194, 58, 0.15); }
+.cam-status.off { color: #f56c6c; background: rgba(245, 108, 108, 0.15); }
+.cam-status.none { color: #909399; background: rgba(144, 147, 153, 0.15); }
+.window-body {
+  flex: 1;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  background: #1a1a2e;
-  color: #fff;
-  gap: 16px;
+  gap: 10px;
+  color: #8a8fa3;
+  font-size: 13px;
 }
-.video-placeholder .el-icon { font-size: 64px; color: #909399; }
-
-.monitor-overlay {
-  position: absolute;
-  top: 16px;
-  right: 16px;
-  background: rgba(0,0,0,0.7);
-  border-radius: 8px;
-  padding: 8px 12px;
-  color: white;
-  font-size: 12px;
-}
-.overlay-item { margin: 4px 0; }
-.overlay-item .label { color: #aaa; margin-right: 8px; }
-.overlay-item.warning .value { color: #f56c6c; font-weight: bold; }
+.window-body .el-icon { font-size: 36px; color: #5a5f73; }
 
 .monitor-cards {
   display: grid;
   grid-template-columns: repeat(4, 1fr);
   gap: 12px;
-  margin-bottom: 16px;
 }
 .card {
   background: #f8f9fc;
@@ -582,31 +526,6 @@ onMounted(() => {
 .card-info .card-value { font-size: 18px; font-weight: bold; color: #1f2d3d; }
 .card-info .unit { font-size: 11px; font-weight: normal; }
 
-.camera-list {
-  background: #f8f9fc;
-  border-radius: 12px;
-  padding: 12px;
-}
-.camera-header { font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
-.camera-grid { display: flex; gap: 12px; flex-wrap: wrap; }
-.camera-item {
-  width: 100px;
-  text-align: center;
-  cursor: pointer;
-  padding: 8px;
-  border-radius: 8px;
-  transition: all 0.2s;
-}
-.camera-item.active { background: #ecf5ff; }
-.camera-preview {
-  background: #e9ecef;
-  border-radius: 8px;
-  padding: 16px;
-  margin-bottom: 4px;
-}
-.camera-preview .el-icon { font-size: 32px; color: #909399; }
-.camera-name { font-size: 11px; color: #606266; }
-
 .empty-video {
   flex: 1;
   display: flex;
@@ -617,15 +536,4 @@ onMounted(() => {
 .alert-content { padding: 8px 0; }
 .alert-detail { margin: 16px 0; padding: 12px; background: #f5f7fa; border-radius: 8px; }
 .alert-actions { display: flex; gap: 12px; justify-content: flex-end; }
-
-.fullscreen-placeholder {
-  width: 100%;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  background: #1a1a2e;
-  color: #fff;
-}
-</style>
+</style>

+ 398 - 0
src/views/subSystem/hidden/HazardLedger.vue

@@ -0,0 +1,398 @@
+<template>
+  <div class="app-container">
+    <!-- 等级统计 -->
+    <div class="stat-row">
+      <div v-for="card in levelStatCards" :key="card.label" class="stat-card">
+        <span class="stat-bar" :style="{ background: card.color }" />
+        <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
+        <div class="stat-label">{{ card.label }}</div>
+      </div>
+    </div>
+
+    <el-form ref="queryRef" :inline="true" :model="searchForm" v-show="showSearch" label-width="80px">
+      <el-form-item label="隐患类型" prop="hazardType">
+        <el-select v-model="searchForm.hazardType" placeholder="请选择隐患类型" clearable style="width: 160px">
+          <el-option v-for="d in hazardTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="隐患等级" prop="hazardLevel">
+        <el-select v-model="searchForm.hazardLevel" placeholder="请选择隐患等级" clearable style="width: 160px">
+          <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="报送状态" prop="isSubmitted">
+        <el-select v-model="searchForm.isSubmitted" placeholder="请选择报送状态" clearable style="width: 160px">
+          <el-option label="未报送" :value="0" />
+          <el-option label="已报送" :value="1" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="所属片区" prop="district">
+        <el-select v-model="searchForm.district" placeholder="请选择片区" clearable style="width: 160px">
+          <el-option v-for="d in districtDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="上报时间" prop="dateRange">
+        <el-date-picker
+          v-model="searchForm.dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          style="width: 240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
+        <el-button icon="Refresh" @click="handleReset">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="loadData" />
+    </el-row>
+
+    <el-table :data="tableData" v-loading="loading">
+      <el-table-column prop="hazardNo" label="隐患编号" width="130" show-overflow-tooltip />
+      <el-table-column prop="hazardType" label="隐患类型" width="100" align="center">
+        <template #default="{ row }">{{ getDictLabel(hazardTypeDict, row.hazardType) }}</template>
+      </el-table-column>
+      <el-table-column prop="hazardLevel" label="隐患等级" width="100" align="center">
+        <template #default="{ row }">
+          <el-tag :type="levelTag(row.hazardLevel)">{{ getDictLabel(hazardLevelDict, row.hazardLevel) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="hazardDesc" label="隐患描述" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="hazardSource" label="隐患来源" width="110" align="center">
+        <template #default="{ row }">{{ getDictLabel(hazardSourceDict, row.hazardSource) }}</template>
+      </el-table-column>
+      <el-table-column prop="district" label="所属片区" width="100" align="center">
+        <template #default="{ row }">{{ getDictLabel(districtDict, row.district) || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="隐患图片" width="90" align="center">
+        <template #default="{ row }">
+          <template v-if="getRowImageUrls(row).length">
+            <el-image
+              :src="getRowImageUrls(row)[0]"
+              fit="contain"
+              lazy
+              :preview-src-list="getRowImageUrls(row)"
+              preview-teleported
+              hide-on-click-modal
+              class="table-image"
+            />
+          </template>
+          <el-tag v-else type="info" size="small">无</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="关联设备" width="140" show-overflow-tooltip>
+        <template #default="{ row }">{{ getEquipmentName(row.equipmentId) }}</template>
+      </el-table-column>
+      <el-table-column prop="reportTime" label="上报时间" width="170" align="center" />
+      <el-table-column label="操作" width="90" fixed="right" align="center" class-name="small-padding fixed-width">
+        <template #default="{ row }">
+          <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      v-model:page="pageNum"
+      v-model:limit="pageSize"
+      :page-sizes="[10, 20, 50]"
+      @pagination="loadData"
+    />
+
+    <!-- 详情抽屉 -->
+    <el-drawer title="隐患详情" v-model="detailVisible" size="900px" class="detail-drawer" 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="所属片区">{{ getDictLabel(districtDict, detailData.district) || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="报送状态">
+          <el-tag :type="detailData.isSubmitted === 1 ? 'success' : 'info'">{{ detailData.isSubmitted === 1 ? '已报送' : '未报送' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="关联设备">{{ getEquipmentName(detailData.equipmentId) }}</el-descriptions-item>
+        <el-descriptions-item label="上报时间">{{ detailData.reportTime || '-' }}</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="整改期限">{{ detailData.rectifyDeadline || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="整改责任人">{{ detailData.rectifyPerson || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="经度">{{ detailData.longitude || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="纬度">{{ detailData.latitude || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="隐患图片" :span="2">
+          <div v-if="detailImageUrls.length" class="detail-image-list">
+            <el-image
+              v-for="(img, index) in detailImageUrls"
+              :key="index"
+              :src="img"
+              fit="contain"
+              lazy
+              :preview-src-list="detailImageUrls"
+              :initial-index="index"
+              preview-teleported
+              hide-on-click-modal
+              class="detail-image"
+            />
+          </div>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, reactive, shallowRef, onMounted } from 'vue'
+import { getHazardPage, getHazardById, getHazardStatisticsByLevel } from '@/api/pipeNetwork/hazard'
+import { getDicts } from '@/api/system/dict/data'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
+import { getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
+
+const loading = ref(false)
+const showSearch = ref(true)
+const queryRef = ref(null)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const searchForm = reactive({ hazardType: '', hazardLevel: '', district: '', isSubmitted: null, dateRange: [] })
+
+// 等级统计(顶部卡片):接口返回 [{name: 等级编码, value: 数量}]
+const levelCountMap = shallowRef({})
+const levelStatCards = computed(() => {
+  const counts = levelCountMap.value
+  // 等级配色与报警等级一致:1 红 / 2 橙 / 3 蓝 / 4 绿
+  const colorMap = { '1': '#f5222d', '2': '#fa8c16', '3': '#1890ff', '4': '#52c41a' }
+  const bgMap = { '1': '#fef0f0', '2': '#fdf6ec', '3': '#ecf5ff', '4': '#f0f9eb' }
+  const cards = [{ label: '隐患总数', value: Object.values(counts).reduce((sum, n) => sum + Number(n || 0), 0), color: '#409eff', bg: '#ecf5ff' }]
+  hazardLevelDict.value.forEach(d => {
+    cards.push({ label: d.dictLabel, value: Number(counts[d.dictValue] || 0), color: colorMap[d.dictValue] || '#909399', bg: bgMap[d.dictValue] || '#f4f4f5' })
+  })
+  return cards
+})
+
+const hazardTypeDict = ref([])
+const hazardLevelDict = ref([])
+const hazardSourceDict = ref([])
+// 所属片区,与字典 drainage_district、DistrictEnum、equipment_base.district 同口径(0城南/1城北/2太常)
+const districtDict = ref([])
+// 选项列表可能较大,使用浅层响应式并缓存请求,避免重复深度更新。
+const equipmentOptions = shallowRef([])
+const equipmentNameCache = shallowRef(new Map())
+const rowImageCache = new WeakMap()
+let equipmentOptionsRequest = null
+
+// 详情相关
+const detailVisible = ref(false)
+const detailData = ref(null)
+const detailImageUrls = computed(() => parseImageUrls(detailData.value?.hazardImages))
+
+function getDictLabel(dict, value) {
+  const item = dict.find(d => d.dictValue === value)
+  return item ? item.dictLabel : value
+}
+
+function levelTag(value) {
+  // 与报警等级一致:1 danger / 2 warning / 3 primary(空串) / 4 success
+  const map = { '1': 'danger', '2': 'warning', '3': '', '4': 'success' }
+  return map[value] || 'info'
+}
+
+/** 将存储的 hazardImages 字符串转为 URL 数组 */
+function parseImageUrls(hazardImages) {
+  if (!hazardImages) return []
+  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 || /^(blob:|data:)/i.test(url)) return url
+  const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
+  if (/^https?:\/\//i.test(url)) return buildMinioAttachmentUrl(url)
+  if (minioBase) {
+    if (url.startsWith('/')) return buildMinioAttachmentUrl(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}`
+}
+
+async function loadDicts() {
+  // 隐患等级统一使用 hazard_level 字典(已对齐报警等级口径:1~4,1 最严重,红/橙/蓝/绿)
+  const [typeRes, levelRes, sourceRes, districtRes] = await Promise.all([
+    getDicts('hazard_type'),
+    getDicts('hazard_level'),
+    getDicts('hazard_source'),
+    getDicts('drainage_district')
+  ])
+  hazardTypeDict.value = typeRes.data
+  hazardLevelDict.value = levelRes.data
+  hazardSourceDict.value = sourceRes.data
+  districtDict.value = districtRes.data
+}
+
+/** 等级统计(全量,不按时间过滤) */
+async function loadLevelStats() {
+  try {
+    const res = await getHazardStatisticsByLevel()
+    const list = res?.data || []
+    const map = {}
+    list.forEach(item => { map[item.name] = Number(item.value || 0) })
+    levelCountMap.value = map
+  } catch (e) { console.error(e) }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const params = {
+      hazardType: searchForm.hazardType,
+      hazardLevel: searchForm.hazardLevel,
+      district: searchForm.district,
+      isSubmitted: searchForm.isSubmitted
+    }
+    if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
+      params.startDate = searchForm.dateRange[0]
+      params.endDate = searchForm.dateRange[1]
+    }
+    const res = await getHazardPage(pageNum.value, pageSize.value, params)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } catch (e) { console.error(e) } finally { loading.value = false }
+}
+function handleSearch() { pageNum.value = 1; loadData() }
+function handleReset() {
+  queryRef.value?.resetFields()
+  Object.assign(searchForm, { hazardType: '', hazardLevel: '', district: '', isSubmitted: null, dateRange: [] })
+  handleSearch()
+}
+async function loadEquipmentOptions() {
+  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 getEquipmentName(id) {
+  return equipmentNameCache.value.get(String(id)) || id || '-'
+}
+async function handleDetail(row) {
+  try {
+    const res = await getHazardById(row.hazardId)
+    detailData.value = res.data || row
+    detailVisible.value = true
+  } catch (e) {
+    // 接口失败时降级使用表格行数据
+    detailData.value = row
+    detailVisible.value = true
+  }
+}
+
+onMounted(async () => {
+  await loadDicts()
+  void loadData()
+  void loadLevelStats()
+  void loadEquipmentOptions()
+})
+</script>
+
+<style scoped>
+/* 等级统计卡片:白底 + 左侧色条,flex 等分一行排开(与隐患排查页一致) */
+.stat-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.stat-card {
+  position: relative;
+  flex: 1;
+  min-width: 0;
+  padding: 14px 16px 14px 22px;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.06);
+}
+.stat-bar {
+  position: absolute;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 4px;
+  border-radius: 6px 0 0 6px;
+}
+.stat-value {
+  font-size: 26px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+.stat-label {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #606266;
+}
+
+/* 表格图片样式 */
+.table-image {
+  width: 40px;
+  height: 40px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+/* 详情图片样式 */
+.detail-image-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+.detail-image {
+  width: 120px;
+  height: 120px;
+  border-radius: 6px;
+  cursor: pointer;
+  object-fit: contain;
+  background: #f5f7fa;
+}
+</style>

+ 167 - 127
src/views/subSystem/hidden/HazardList.vue

@@ -1,5 +1,14 @@
 <template>
   <div class="app-container">
+    <!-- 等级统计 -->
+    <div class="stat-row">
+      <div v-for="card in levelStatCards" :key="card.label" class="stat-card">
+        <span class="stat-bar" :style="{ background: card.color }" />
+        <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
+        <div class="stat-label">{{ card.label }}</div>
+      </div>
+    </div>
+
     <el-form ref="queryRef" :inline="true" :model="searchForm" v-show="showSearch" label-width="80px">
       <el-form-item label="隐患类型" prop="hazardType">
         <el-select v-model="searchForm.hazardType" placeholder="请选择隐患类型" clearable style="width: 160px">
@@ -11,22 +20,21 @@
           <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
         </el-select>
       </el-form-item>
-      <el-form-item label="状态" prop="hazardStatus">
-        <el-select v-model="searchForm.hazardStatus" placeholder="请选择状态" clearable style="width: 160px">
-          <el-option v-for="d in hazardStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="报送状态" prop="isSubmitted">
-        <el-select v-model="searchForm.isSubmitted" placeholder="请选择报送状态" clearable style="width: 160px">
-          <el-option label="未报送" :value="0" />
-          <el-option label="已报送" :value="1" />
+      <el-form-item label="所属片区" prop="district">
+        <el-select v-model="searchForm.district" placeholder="请选择片区" clearable style="width: 160px">
+          <el-option v-for="d in districtDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
         </el-select>
       </el-form-item>
-      <el-form-item label="关联管网" prop="networkId">
-        <el-select-v2 v-model="searchForm.networkId" placeholder="请选择关联管网" clearable filterable style="width: 200px" :options="networkSelectOptions" @focus="loadNetworkOptions" />
-      </el-form-item>
-      <el-form-item label="关联设备" prop="equipmentId">
-        <el-select-v2 v-model="searchForm.equipmentId" placeholder="请选择关联设备" clearable filterable style="width: 200px" :options="equipmentSelectOptions" @focus="loadEquipmentOptions" />
+      <el-form-item label="上报时间" prop="dateRange">
+        <el-date-picker
+          v-model="searchForm.dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          style="width: 240px"
+        />
       </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
@@ -55,10 +63,8 @@
       <el-table-column prop="hazardSource" label="隐患来源" width="110" align="center">
         <template #default="{ row }">{{ getDictLabel(hazardSourceDict, row.hazardSource) }}</template>
       </el-table-column>
-      <el-table-column prop="hazardStatus" label="状态" width="100" align="center">
-        <template #default="{ row }">
-          <el-tag :type="statusTag(row.hazardStatus)">{{ getDictLabel(hazardStatusDict, row.hazardStatus) }}</el-tag>
-        </template>
+      <el-table-column prop="district" label="所属片区" width="100" align="center">
+        <template #default="{ row }">{{ getDictLabel(districtDict, row.district) || '-' }}</template>
       </el-table-column>
       <el-table-column label="隐患图片" width="90" align="center">
         <template #default="{ row }">
@@ -76,19 +82,21 @@
           <el-tag v-else type="info" size="small">无</el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="关联管网" width="140" show-overflow-tooltip>
-        <template #default="{ row }">{{ getNetworkName(row.networkId) }}</template>
-      </el-table-column>
       <el-table-column label="关联设备" width="140" show-overflow-tooltip>
         <template #default="{ row }">{{ getEquipmentName(row.equipmentId) }}</template>
       </el-table-column>
-      <el-table-column prop="reportUnit" label="上报单位" width="130" show-overflow-tooltip />
       <el-table-column prop="reportTime" label="上报时间" width="170" align="center" />
       <el-table-column label="操作" width="200" fixed="right" align="center" class-name="small-padding fixed-width">
         <template #default="{ row }">
-          <el-button link type="primary" icon="Edit" @click="handleEdit(row)">修改</el-button>
-          <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
-          <el-button link type="danger" icon="Delete" @click="handleDelete(row)">删除</el-button>
+          <!-- 权限控制:仅未报送数据支持修改、删除 -->
+          <template v-if="row.isSubmitted === 0">
+            <el-button link type="primary" icon="Edit" @click="handleEdit(row)">修改</el-button>
+            <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
+            <el-button link type="danger" icon="Delete" @click="handleDelete(row)">删除</el-button>
+          </template>
+          <template v-else>
+            <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
+          </template>
         </template>
       </el-table-column>
     </el-table>
@@ -106,11 +114,6 @@
     <el-dialog :title="dialogTitle" v-model="dialogVisible" width="750px" append-to-body destroy-on-close>
       <el-form :model="formData" ref="formRef" label-width="100px" :rules="rules">
         <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="隐患编号" prop="hazardNo">
-              <el-input v-model="formData.hazardNo" placeholder="保存时自动生成" readonly />
-            </el-form-item>
-          </el-col>
           <el-col :span="12">
             <el-form-item label="隐患类型" prop="hazardType">
               <el-select v-model="formData.hazardType" style="width: 100%">
@@ -133,18 +136,15 @@
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="关联管网">
-              <el-select-v2 v-model="formData.networkId" placeholder="请选择" clearable filterable style="width: 100%" :options="networkSelectOptions" />
+            <el-form-item label="所属片区" prop="district">
+              <el-select v-model="formData.district" style="width: 100%" placeholder="请选择所属片区" clearable>
+                <el-option v-for="d in districtDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="关联设备" prop="equipmentId">
-              <el-select-v2 v-model="formData.equipmentId" placeholder="请选择" clearable filterable style="width: 100%" :options="equipmentSelectOptions" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="上报单位">
-              <el-input v-model="formData.reportUnit" />
+              <el-select-v2 v-model="formData.equipmentId" placeholder="请选择" clearable filterable style="width: 100%" :options="equipmentSelectOptions" @change="handleEquipmentChange" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -157,31 +157,11 @@
               <el-input v-model="formData.hazardDesc" type="textarea" :rows="3" placeholder="请输入隐患描述" />
             </el-form-item>
           </el-col>
-          <el-col :span="24">
-            <el-form-item label="整改方案">
-              <el-input v-model="formData.rectifyPlan" type="textarea" :rows="3" />
-            </el-form-item>
-          </el-col>
           <el-col :span="12">
             <el-form-item label="整改期限">
               <el-date-picker v-model="formData.rectifyDeadline" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width: 100%" />
             </el-form-item>
           </el-col>
-          <el-col :span="12">
-            <el-form-item label="整改责任人">
-              <el-input v-model="formData.rectifyPerson" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="经度">
-              <el-input-number v-model="formData.longitude" :precision="10" style="width: 100%" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="纬度">
-              <el-input-number v-model="formData.latitude" :precision="10" style="width: 100%" />
-            </el-form-item>
-          </el-col>
           <el-col :span="24">
             <el-form-item label="隐患图片" class="image-form-item">
               <div class="image-upload-content">
@@ -220,7 +200,7 @@
       </template>
     </el-dialog>
 
-    <!-- 详情对话框 -->
+    <!-- 详情抽屉 -->
     <el-drawer title="隐患详情" v-model="detailVisible" size="900px" class="detail-drawer" direction="rtl">
       <el-descriptions v-if="detailData" :column="2" border>
         <el-descriptions-item label="隐患编号">{{ detailData.hazardNo }}</el-descriptions-item>
@@ -229,15 +209,11 @@
           <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="所属片区">{{ getDictLabel(districtDict, detailData.district) || '-' }}</el-descriptions-item>
         <el-descriptions-item label="报送状态">
           <el-tag :type="detailData.isSubmitted === 1 ? 'success' : 'info'">{{ detailData.isSubmitted === 1 ? '已报送' : '未报送' }}</el-tag>
         </el-descriptions-item>
-        <el-descriptions-item label="关联管网">{{ getNetworkName(detailData.networkId) }}</el-descriptions-item>
         <el-descriptions-item label="关联设备">{{ getEquipmentName(detailData.equipmentId) }}</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="隐患描述" :span="2">{{ detailData.hazardDesc || '-' }}</el-descriptions-item>
         <el-descriptions-item label="整改方案" :span="2">{{ detailData.rectifyPlan || '-' }}</el-descriptions-item>
@@ -281,12 +257,11 @@
 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 { createMinioBatchUploader } from '@/utils/minioBatchUpload'
-import { getHazardPage, getHazardById, addHazard, updateHazard, deleteHazard } from '@/api/pipeNetwork/hazard'
+import { getHazardPage, getHazardById, getHazardStatisticsByLevel, addHazard, updateHazard, deleteHazard } from '@/api/pipeNetwork/hazard'
 import { getDicts } from '@/api/system/dict/data'
-import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
-import { getPipeNetworkOptions, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
+import { buildMinioAttachmentUrl, minioAttachmentPath } from '@/utils/minioAttachment'
+import { getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
 
 const loading = ref(false)
 const showSearch = ref(true)
@@ -295,7 +270,7 @@ const tableData = ref([])
 const pageNum = ref(1)
 const pageSize = ref(10)
 const total = ref(0)
-const searchForm = reactive({ hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null, networkId: '', equipmentId: '' })
+const searchForm = reactive({ hazardType: '', hazardLevel: '', dateRange: [] })
 const dialogVisible = ref(false)
 const dialogTitle = ref('新增')
 const formRef = ref(null)
@@ -312,25 +287,36 @@ const rules = {
 
 const formData = reactive({
   hazardId: '', hazardNo: '', hazardType: '', hazardLevel: '', hazardDesc: '',
-  hazardSource: '', hazardStatus: '待核查', reportUnit: '', reportTime: '',
+  hazardSource: '', district: '', hazardStatus: '待核查', reportTime: '',
   rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '',
-  networkId: '', equipmentId: '', longitude: null, latitude: null,
+  equipmentId: '', longitude: null, latitude: null,
   hazardImages: ''
 })
 
+// 等级统计(顶部卡片):接口返回 [{name: 等级编码, value: 数量}]
+const levelCountMap = shallowRef({})
+const levelStatCards = computed(() => {
+  const counts = levelCountMap.value
+  // 等级配色与报警等级一致:1 红 / 2 橙 / 3 蓝 / 4 绿
+  const colorMap = { '1': '#f5222d', '2': '#fa8c16', '3': '#1890ff', '4': '#52c41a' }
+  const bgMap = { '1': '#fef0f0', '2': '#fdf6ec', '3': '#ecf5ff', '4': '#f0f9eb' }
+  const cards = [{ label: '隐患总数', value: Object.values(counts).reduce((sum, n) => sum + Number(n || 0), 0), color: '#409eff', bg: '#ecf5ff' }]
+  hazardLevelDict.value.forEach(d => {
+    cards.push({ label: d.dictLabel, value: Number(counts[d.dictValue] || 0), color: colorMap[d.dictValue] || '#909399', bg: bgMap[d.dictValue] || '#f4f4f5' })
+  })
+  return cards
+})
+
 const hazardTypeDict = ref([])
 const hazardLevelDict = ref([])
-const hazardStatusDict = ref([])
 const hazardSourceDict = ref([])
-// 选项列表可能较大,使用浅层响应式并缓存请求,避免每次编辑都触发整棵数组深度更新。
-const networkOptions = shallowRef([])
+// 所属片区,与字典 drainage_district、DistrictEnum、equipment_base.district 同口径(0城南/1城北/2太常)
+const districtDict = ref([])
+// 选项列表可能较大,使用浅层响应式并缓存请求,避免重复深度更新。
 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
 
 // 详情相关
@@ -352,11 +338,8 @@ function getDictLabel(dict, value) {
 }
 
 function levelTag(value) {
-  const map = { '0': 'danger', '1': 'warning', '2': 'info', '3': '' }
-  return map[value] || 'info'
-}
-function statusTag(value) {
-  const map = { '待核查': 'info', '整改中': 'warning', '已整改': 'success', '已闭环': '' }
+  // 与报警等级一致:1 danger / 2 warning / 3 primary(空串) / 4 success
+  const map = { '1': 'danger', '2': 'warning', '3': '', '4': 'success' }
   return map[value] || 'info'
 }
 
@@ -406,6 +389,42 @@ function normalizeImageUrl(value) {
   return url.startsWith('/') ? `${baseApi}${url}` : `${baseApi}/${url}`
 }
 
+async function loadDicts() {
+  // 隐患等级统一使用 hazard_level 字典(已对齐报警等级口径:1~4,1 最严重,红/橙/蓝/绿)
+  const [typeRes, levelRes, sourceRes, districtRes] = await Promise.all([
+    getDicts('hazard_type'),
+    getDicts('hazard_level'),
+    getDicts('hazard_source'),
+    getDicts('drainage_district')
+  ])
+  hazardTypeDict.value = typeRes.data
+  hazardLevelDict.value = levelRes.data
+  hazardSourceDict.value = sourceRes.data
+  districtDict.value = districtRes.data
+}
+
+/** 等级统计(全量,不按时间过滤) */
+async function loadLevelStats() {
+  try {
+    const res = await getHazardStatisticsByLevel()
+    const list = res?.data || []
+    const map = {}
+    list.forEach(item => { map[item.name] = Number(item.value || 0) })
+    levelCountMap.value = map
+  } catch (e) { console.error(e) }
+}
+
+/** 选择关联设备后自动带出经纬度写入 formData(表单已不展示,仅供隐患一张图取点;
+ *  equipment_base 存 WGS84,与隐患表同口径,直接拷贝) */
+function handleEquipmentChange(equipmentId) {
+  if (!equipmentId) { formData.longitude = null; formData.latitude = null; return }
+  const eq = equipmentOptions.value.find(item => String(item.equipmentId) === String(equipmentId))
+  if (!eq) return
+  const lng = Number(eq.longitude), lat = Number(eq.latitude)
+  formData.longitude = Number.isFinite(lng) ? lng : null
+  formData.latitude = Number.isFinite(lat) ? lat : null
+}
+
 /** 图片选择后立即上传到 MinIO(同一批次多张合并为一次 /minio/uploads 请求) */
 const hazardImageUploader = createMinioBatchUploader('hazard')
 hazardImageUploader.onBatchSettled(() => { imageUploading.value = false })
@@ -418,10 +437,12 @@ async function handleImageChange(file) {
   imageUploading.value = true
   try {
     const info = await hazardImageUploader(file)
-    const url = normalizeImageUrl(info.relativePath || info.url || info.fileName)
-    if (!url) throw new Error('上传接口未返回图片地址')
-    imageUrls.value.push(url)
-    imageList.value.push(url)
+    const displayUrl = normalizeImageUrl(info.relativePath || info.url || info.fileName)
+    if (!displayUrl) throw new Error('上传接口未返回图片地址')
+    // imageUrls 存 MinIO 相对路径(入库口径,工单校验只认相对路径);imageList 存展示用完整地址
+    const relativePath = minioAttachmentPath(info.relativePath || info.url || info.fileName)
+    imageUrls.value.push(relativePath)
+    imageList.value.push(displayUrl)
   } catch (e) {
     ElMessage.error('图片上传失败')
   }
@@ -440,23 +461,21 @@ function previewImage(index) {
   previewVisible.value = true
 }
 
-async function loadDicts() {
-  const [typeRes, levelRes, statusRes, sourceRes] = await Promise.all([
-    getDicts('hazard_type'),
-    getDicts('hazard_level'),
-    getDicts('hazard_status'),
-    getDicts('hazard_source')
-  ])
-  hazardTypeDict.value = typeRes.data
-  hazardLevelDict.value = levelRes.data
-  hazardStatusDict.value = statusRes.data
-  hazardSourceDict.value = sourceRes.data
-}
-
 async function loadData() {
   loading.value = true
   try {
-    const res = await getHazardPage(pageNum.value, pageSize.value, searchForm)
+    const params = {
+      hazardType: searchForm.hazardType,
+      hazardLevel: searchForm.hazardLevel,
+      district: searchForm.district,
+      // 隐患排查只看未报送数据;已报送的走隐患报送/台账页
+      isSubmitted: 0
+    }
+    if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
+      params.startDate = searchForm.dateRange[0]
+      params.endDate = searchForm.dateRange[1]
+    }
+    const res = await getHazardPage(pageNum.value, pageSize.value, params)
     tableData.value = res.data.records || []
     total.value = res.data.total || 0
   } catch (e) { console.error(e) } finally { loading.value = false }
@@ -464,22 +483,9 @@ async function loadData() {
 function handleSearch() { pageNum.value = 1; loadData() }
 function handleReset() {
   queryRef.value?.resetFields()
-  Object.assign(searchForm, { hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null, networkId: '', equipmentId: '' })
+  Object.assign(searchForm, { hazardType: '', hazardLevel: '', district: '', dateRange: [] })
   handleSearch()
 }
-async function loadNetworkOptions() {
-  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 (Array.isArray(equipmentOptions.value) && equipmentOptions.value.length > 0) return
   if (equipmentOptionsRequest) return equipmentOptionsRequest
@@ -493,9 +499,6 @@ async function loadEquipmentOptions() {
     .finally(() => { equipmentOptionsRequest = null })
   return equipmentOptionsRequest
 }
-function getNetworkName(id) {
-  return networkNameCache.value.get(String(id)) || id || '-'
-}
 function getEquipmentName(id) {
   return equipmentNameCache.value.get(String(id)) || id || '-'
 }
@@ -509,20 +512,20 @@ function generateHazardNo() {
 }
 function handleAdd() {
   dialogTitle.value = '新增'
-  Object.assign(formData, { hazardId: '', hazardNo: generateHazardNo(), hazardType: '', hazardLevel: '', hazardDesc: '', hazardSource: '', hazardStatus: '待核查', reportUnit: '', reportTime: '', rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '', networkId: '', equipmentId: '', longitude: null, latitude: null, hazardImages: '', isSubmitted: null, submitTime: null, verifyPerson: null, verifyTime: null })
+  // 隐患编号不在表单展示,新增时自动生成(后端 saveWithCheck 会校验唯一)
+  Object.assign(formData, { hazardId: '', hazardNo: generateHazardNo(), hazardType: '', hazardLevel: '', hazardDesc: '', hazardSource: '', district: '', hazardStatus: '待核查', reportTime: '', rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '', equipmentId: '', longitude: null, latitude: null, hazardImages: '', isSubmitted: null, submitTime: null, verifyPerson: null, verifyTime: null })
   imageList.value = []
   imageUrls.value = []
   dialogVisible.value = true
-  loadNetworkOptions(); loadEquipmentOptions()
+  loadEquipmentOptions()
 }
 function handleEdit(row) {
   dialogTitle.value = '编辑'
   // 只回填表单字段,避免把接口返回的分页/审计字段直接写入响应式表单。
-  // 直接 Object.assign(row) 在部分数据返回结构下会导致弹窗渲染持续触发更新。
   const editableFields = [
     'hazardId', 'hazardNo', 'hazardType', 'hazardLevel', 'hazardDesc',
-    'hazardSource', 'hazardStatus', 'reportUnit', 'reportTime',
-    'rectifyPlan', 'rectifyDeadline', 'rectifyPerson', 'networkId',
+    'hazardSource', 'district', 'hazardStatus', 'reportTime',
+    'rectifyPlan', 'rectifyDeadline', 'rectifyPerson',
     'equipmentId', 'longitude', 'latitude', 'hazardImages'
   ]
   editableFields.forEach(key => {
@@ -535,13 +538,13 @@ function handleEdit(row) {
       formData[key] = value ?? ''
     }
   })
-  // 回显已有图片
+  // 回显已有图片:imageList 存展示地址,imageUrls 规整为相对路径(保存入库口径,兼容历史绝对地址)
   const urls = getRowImageUrls(row)
-  imageUrls.value = [...urls]
+  imageUrls.value = urls.map(item => minioAttachmentPath(item))
   imageList.value = [...urls]
   dialogVisible.value = true
   // 选项加载不阻塞弹窗打开,且函数内部会避免重复请求。
-  void Promise.allSettled([loadNetworkOptions(), loadEquipmentOptions()])
+  void Promise.allSettled([loadEquipmentOptions()])
 }
 async function handleSave() {
   if (!formRef.value) return
@@ -552,7 +555,7 @@ async function handleSave() {
     formData.hazardImages = imageUrls.value.join(',')
     if (formData.hazardId) { await updateHazard(formData); ElMessage.success('修改成功') }
     else { await addHazard(formData); ElMessage.success('新增成功') }
-    dialogVisible.value = false; loadData()
+    dialogVisible.value = false; loadData(); loadLevelStats()
   } catch (e) {
     const msg = e?.response?.data?.msg || e?.message || '操作失败'
     ElMessage.error(msg)
@@ -560,7 +563,7 @@ async function handleSave() {
 }
 function handleDelete(row) {
   ElMessageBox.confirm('确认删除该隐患记录?', '提示', { type: 'warning' }).then(async () => {
-    await deleteHazard(row.hazardId); ElMessage.success('删除成功'); loadData()
+    await deleteHazard(row.hazardId); ElMessage.success('删除成功'); loadData(); loadLevelStats()
   }).catch(() => {})
 }
 async function handleDetail(row) {
@@ -578,10 +581,47 @@ async function handleDetail(row) {
 onMounted(async () => {
   await loadDicts()
   void loadData()
+  void loadLevelStats()
+  void loadEquipmentOptions()
 })
 </script>
 
 <style scoped>
+/* 等级统计卡片:白底 + 左侧色条,flex 等分一行排开 */
+.stat-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.stat-card {
+  position: relative;
+  flex: 1;
+  min-width: 0;
+  padding: 14px 16px 14px 22px;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.06);
+}
+.stat-bar {
+  position: absolute;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 4px;
+  border-radius: 6px 0 0 6px;
+}
+.stat-value {
+  font-size: 26px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+.stat-label {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #606266;
+}
+
 /* 表格图片样式 */
 .table-image {
   width: 40px;

+ 40 - 36
src/views/subSystem/hidden/HazardMap.vue

@@ -14,9 +14,9 @@
               <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
             </el-select>
           </el-form-item>
-          <el-form-item label="整改状态">
+          <el-form-item label="隐患状态">
             <el-select v-model="filterStatus" placeholder="全部" clearable style="width:140px" @change="onStatusFilterChange">
-              <el-option v-for="d in hazardStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
+              <el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
             </el-select>
           </el-form-item>
           <el-form-item>
@@ -41,13 +41,13 @@
         <div class="status-tabs">
           <span class="status-tab" :class="{ active: listStatusTab === '' }" @click="switchStatusTab('')">全部</span>
           <span
-            v-for="d in hazardStatusDict"
-            :key="d.dictValue"
+            v-for="s in statusOptions"
+            :key="s.value"
             class="status-tab"
-            :class="{ active: listStatusTab === d.dictValue }"
-            :style="listStatusTab === d.dictValue ? { color: statusTabColor(d.dictValue), borderBottomColor: statusTabColor(d.dictValue) } : {}"
-            @click="switchStatusTab(d.dictValue)"
-          >{{ d.dictLabel }}</span>
+            :class="{ active: listStatusTab === s.value }"
+            :style="listStatusTab === s.value ? { color: statusTabColor(s.value), borderBottomColor: statusTabColor(s.value) } : {}"
+            @click="switchStatusTab(s.value)"
+          >{{ s.label }}</span>
         </div>
       </template>
       <div class="list-body">
@@ -61,12 +61,12 @@
           <div class="item-top">
             <span class="dot" :style="{ background: levelColors[h.hazardLevel] }"></span>
             <span class="item-no">{{ h.hazardNo }}</span>
-            <el-tag :type="statusTag(h.hazardStatus)" size="small">{{ getDictLabel(hazardStatusDict, h.hazardStatus) }}</el-tag>
+            <el-tag :type="statusTag(statusText(h))" size="small">{{ statusText(h) }}</el-tag>
           </div>
           <div class="item-info">
             <span>{{ getDictLabel(hazardTypeDict, h.hazardType) }}</span>
             <span class="divider">|</span>
-            <span :style="{ color: levelColors[h.hazardLevel], fontWeight: '500' }">{{ getDictLabel(hazardLevelDict, h.hazardLevel) }}隐患</span>
+            <span :style="{ color: levelColors[h.hazardLevel], fontWeight: '500' }">{{ getDictLabel(hazardLevelDict, h.hazardLevel) }}</span>
           </div>
           <div class="item-addr">{{ h.remark || h.hazardDesc }}</div>
           <div class="item-time">{{ h.reportTime }}</div>
@@ -81,7 +81,7 @@
       <div class="legend-items">
         <div class="legend-item" v-for="item in levelLegend" :key="item.value">
           <span class="legend-dot" :style="{ background: item.color }"></span>
-          <span>{{ item.label }}隐患</span>
+          <span>{{ item.label }}</span>
           <span class="legend-count">{{ item.count }}</span>
         </div>
       </div>
@@ -96,8 +96,9 @@
           <el-descriptions-item label="隐患等级">
             <el-tag :type="levelTag(currentHazard.hazardLevel)">{{ getDictLabel(hazardLevelDict, currentHazard.hazardLevel) }}</el-tag>
           </el-descriptions-item>
-          <el-descriptions-item label="整改状态">{{ getDictLabel(hazardStatusDict, currentHazard.hazardStatus) }}</el-descriptions-item>
-          <el-descriptions-item label="上报单位">{{ currentHazard.reportUnit }}</el-descriptions-item>
+          <el-descriptions-item label="隐患状态">
+            <el-tag :type="statusTag(statusText(currentHazard))">{{ statusText(currentHazard) }}</el-tag>
+          </el-descriptions-item>
           <el-descriptions-item label="上报时间">{{ currentHazard.reportTime }}</el-descriptions-item>
           <el-descriptions-item label="隐患描述">{{ currentHazard.hazardDesc }}</el-descriptions-item>
           <el-descriptions-item label="备注">{{ currentHazard.remark || '-' }}</el-descriptions-item>
@@ -126,7 +127,13 @@ let mapMarkers = []
 
 const hazardTypeDict = ref([])
 const hazardLevelDict = ref([])
-const hazardStatusDict = ref([])
+
+// 隐患状态:仅保留一套枚举(未报送/已报送/已反馈),不再读 hazard_status 字典
+const statusOptions = [
+  { value: '未报送', label: '未报送' },
+  { value: '已报送', label: '已报送' },
+  { value: '已反馈', label: '已反馈' }
+]
 
 const filterType = ref('')
 const filterLevel = ref('')
@@ -138,18 +145,25 @@ function getDictLabel(dict, value) {
   return item ? item.dictLabel : value
 }
 
-const levelColors = { '0': '#f5222d', '1': '#fa8c16', '2': '#1890ff', '3': '#52c41a' }
+// 状态推导:有整改反馈=已反馈;否则按是否已报送(后端 map-points 已返回 isSubmitted)
+function statusText(h) {
+  if (h && h.rectifyFeedback) return '已反馈'
+  return h && h.isSubmitted === 1 ? '已报送' : '未报送'
+}
+
+// 隐患等级色:与报警等级字典保持一致(1 一级最严重红 / 2 橙 / 3 蓝 / 4 绿)
+const levelColors = { '1': '#f5222d', '2': '#fa8c16', '3': '#1890ff', '4': '#52c41a' }
 
 function levelTag(level) {
-  const map = { '0': 'danger', '1': 'warning', '2': '', '3': 'success' }
+  const map = { '1': 'danger', '2': 'warning', '3': '', '4': 'success' }
   return map[String(level)] || ''
 }
 function statusTag(status) {
-  const map = { '待核查': 'info', '整改中': 'warning', '已整改': 'success', '已闭环': '' }
+  const map = { '未报送': 'info', '已报送': '', '已反馈': 'success' }
   return map[status] || 'info'
 }
 function statusTabColor(status) {
-  const map = { '待核查': '#909399', '整改中': '#e6a23c', '已整改': '#67c23a', '已闭环': '#409eff' }
+  const map = { '未报送': '#909399', '已报送': '#409eff', '已反馈': '#67c23a' }
   return map[status] || '#409eff'
 }
 function switchStatusTab(val) {
@@ -165,7 +179,7 @@ const filteredHazards = computed(() => {
   let data = allHazards.value
   if (filterType.value) data = data.filter(h => h.hazardType === filterType.value)
   if (filterLevel.value) data = data.filter(h => h.hazardLevel === filterLevel.value)
-  if (listStatusTab.value) data = data.filter(h => h.hazardStatus === listStatusTab.value)
+  if (listStatusTab.value) data = data.filter(h => statusText(h) === listStatusTab.value)
   return data
 })
 
@@ -272,14 +286,12 @@ function openHazardInfo(h) {
   const color = levelColors[h.hazardLevel] || '#999'
   const levelLabel = getDictLabel(hazardLevelDict.value, h.hazardLevel)
   const typeLabel = getDictLabel(hazardTypeDict.value, h.hazardType)
-  const statusLabel = getDictLabel(hazardStatusDict.value, h.hazardStatus)
 
   const html = `<div style="font-size:13px;max-width:260px;line-height:1.8;">
     <b>${h.hazardNo}</b><br/>
     隐患类型:${typeLabel}<br/>
     隐患等级:<span style="color:${color};font-weight:bold;">${levelLabel}</span><br/>
-    整改状态:${statusLabel}<br/>
-    上报单位:${h.reportUnit}<br/>
+    隐患状态:${statusText(h)}<br/>
     地址:${h.address}
   </div>`
   activeInfoWindow = new TdtMap.InfoWindow(html, { width: 260, height: 150 })
@@ -321,14 +333,12 @@ function resetFilter() {
 // ==================== 生命周期 ====================
 async function loadDicts() {
   try {
-    const [typeRes, levelRes, statusRes] = await Promise.all([
+    const [typeRes, levelRes] = await Promise.all([
       getDicts('hazard_type'),
-      getDicts('hazard_level'),
-      getDicts('hazard_status')
+      getDicts('hazard_level')
     ])
     hazardTypeDict.value = typeRes.data || []
     hazardLevelDict.value = levelRes.data || []
-    hazardStatusDict.value = statusRes.data || []
   } catch (e) {
     hazardTypeDict.value = [
       { dictValue: '0', dictLabel: '第三方施工破坏' },
@@ -338,16 +348,10 @@ async function loadDicts() {
       { dictValue: '4', dictLabel: '设备故障' }
     ]
     hazardLevelDict.value = [
-      { dictValue: '0', dictLabel: '重大' },
-      { dictValue: '1', dictLabel: '较大' },
-      { dictValue: '2', dictLabel: '一般' },
-      { dictValue: '3', dictLabel: '低风险' }
-    ]
-    hazardStatusDict.value = [
-      { dictValue: '待核查', dictLabel: '待核查' },
-      { dictValue: '整改中', dictLabel: '整改中' },
-      { dictValue: '已整改', dictLabel: '已整改' },
-      { dictValue: '已闭环', dictLabel: '已闭环' }
+      { dictValue: '1', dictLabel: '一级隐患' },
+      { dictValue: '2', dictLabel: '二级隐患' },
+      { dictValue: '3', dictLabel: '三级隐患' },
+      { dictValue: '4', dictLabel: '四级隐患' }
     ]
   }
 }

+ 197 - 150
src/views/subSystem/hidden/HazardOperationPage.vue

@@ -5,7 +5,7 @@
         <el-input
           v-model="queryParams.keyword"
           clearable
-          placeholder="隐患编号/描述/上报单位"
+          placeholder="隐患编号/描述"
           style="width: 220px"
           @keyup.enter="handleSearch"
         />
@@ -25,21 +25,20 @@
           <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="报送状态" prop="isSubmitted">
-        <el-select v-model="queryParams.isSubmitted" clearable placeholder="全部" style="width: 140px">
-          <el-option label="未报送" :value="0" />
-          <el-option label="已报送" :value="1" />
+      <el-form-item label="所属片区" prop="district">
+        <el-select v-model="queryParams.district" clearable placeholder="全部" style="width: 140px">
+          <el-option v-for="item in districtDict" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
         </el-select>
       </el-form-item>
-      <el-form-item v-if="mode !== 'submission'" label="治理状态" prop="hazardStatus">
+      <el-form-item v-if="mode === 'submission'" label="治理状态" prop="hazardStatus">
         <el-select v-model="queryParams.hazardStatus" clearable placeholder="全部" style="width: 140px">
           <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="时间类型" prop="timeType">
-        <el-select v-model="queryParams.timeType" style="width: 150px">
-          <el-option label="隐患上报时间" value="report" />
-          <el-option label="报送时间" value="submit" />
+      <el-form-item v-if="mode === 'feedback'" label="反馈状态" prop="feedbackStatus">
+        <el-select v-model="queryParams.feedbackStatus" clearable placeholder="全部" style="width: 140px">
+          <el-option label="未反馈" value="0" />
+          <el-option label="已反馈" value="1" />
         </el-select>
       </el-form-item>
       <el-form-item label="时间范围">
@@ -77,38 +76,23 @@
       <el-table-column label="隐患来源" width="125" align="center">
         <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="district" label="所属片区" width="100" align="center">
+        <template #default="{ row }">{{ getDictLabel(districtDict, row.district) || '-' }}</template>
+      </el-table-column>
       <el-table-column prop="reportTime" label="上报时间" width="165" align="center" />
 
-      <template v-if="mode === 'submission'">
-        <el-table-column label="报送状态" width="110" align="center">
+      <template v-if="mode === 'feedback'">
+        <el-table-column label="反馈状态" width="110" align="center">
           <template #default="{ row }">
-            <el-tag :type="isSubmitted(row) ? 'success' : 'info'">{{ isSubmitted(row) ? '已报送' : '未报送' }}</el-tag>
+            <el-tag :type="hasFeedback(row) ? 'success' : 'info'">{{ hasFeedback(row) ? '已反馈' : '未反馈' }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="submitTime" label="报送时间" width="165" align="center">
-          <template #default="{ row }">{{ row.submitTime || '-' }}</template>
-        </el-table-column>
-      </template>
-
-      <template v-else>
-        <el-table-column label="治理状态" width="110" align="center">
-          <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" align="center">
-          <template #default="{ row }">{{ row.rectifyPerson || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="rectifyDeadline" label="整改期限" width="120" align="center">
-          <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" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="操作" :width="mode === 'submission' ? 150 : 250" fixed="right" align="center" class-name="small-padding fixed-width">
         <template #default="{ row }">
           <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
           <el-button
@@ -121,7 +105,18 @@
           >
             {{ isSubmitted(row) ? '已报送' : '报送' }}
           </el-button>
-          <el-button v-else link type="warning" icon="Edit" @click="handleFeedback(row)">治理反馈</el-button>
+          <template v-else>
+            <el-button
+              link
+              :type="hasFeedback(row) ? 'success' : 'warning'"
+              icon="Edit"
+              :disabled="hasFeedback(row)"
+              @click="handleFeedback(row)"
+            >
+              {{ hasFeedback(row) ? '已反馈' : '治理反馈' }}
+            </el-button>
+            <el-button v-if="row.workOrderId" link type="primary" icon="Tickets" @click="handleViewOrder(row)">查看工单</el-button>
+          </template>
         </template>
       </el-table-column>
     </el-table>
@@ -143,19 +138,30 @@
           <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-descriptions-item label="所属片区">{{ getDictLabel(districtDict, detailData.district) || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="治理状态" v-if="mode === 'submission'">
           <el-tag :type="statusTag(detailData.hazardStatus)">{{ getDictLabel(hazardStatusDict, detailData.hazardStatus) }}</el-tag>
         </el-descriptions-item>
+        <el-descriptions-item label="反馈状态" v-else>
+          <el-tag :type="hasFeedback(detailData) ? 'success' : 'info'">{{ hasFeedback(detailData) ? '已反馈' : '未反馈' }}</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 v-if="mode === 'feedback'" label="关联工单" :span="2">
+          <template v-if="detailOrder">
+            <el-tag :type="orderStatusType(detailOrder.orderStatus)" class="order-tag">{{ orderStatusText(detailOrder.orderStatus) }}</el-tag>
+            <span class="order-no">{{ detailOrder.orderNo || detailOrder.orderId }}</span>
+          </template>
+          <span v-else-if="detailData.workOrderId">工单ID:{{ detailData.workOrderId }}</span>
+          <span v-else>-</span>
+        </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 v-if="mode === 'submission'" label="核查人">{{ detailData.verifyPerson || '-' }}</el-descriptions-item>
+        <el-descriptions-item v-if="mode === 'submission'" label="核查时间">{{ detailData.verifyTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item v-if="mode === 'submission'" label="整改责任人">{{ detailData.rectifyPerson || '-' }}</el-descriptions-item>
+        <el-descriptions-item v-if="mode === 'submission'" 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>
@@ -186,70 +192,54 @@
           :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>
+        <!-- 关联工单信息:辅助填写治理反馈内容 -->
+        <div v-if="feedbackOrder || feedbackOrderLoading" v-loading="feedbackOrderLoading" class="feedback-order">
+          <div class="feedback-order-title">关联工单信息</div>
+          <el-descriptions v-if="feedbackOrder" :column="2" border size="small">
+            <el-descriptions-item label="工单编号">{{ feedbackOrder.orderNo || feedbackOrder.orderId || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="工单状态">
+              <el-tag :type="orderStatusType(feedbackOrder.orderStatus)" size="small">{{ orderStatusText(feedbackOrder.orderStatus) }}</el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="设备名称">{{ feedbackOrder.deviceName || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="处置人">{{ feedbackOrder.receiveUserName || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="故障发生时间">{{ feedbackOrder.faultTime || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="计划完成时间">{{ feedbackOrder.planFinishTime || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="实际完成时间">{{ feedbackOrder.realFinishTime || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="核验人">{{ feedbackOrder.verifyUserName || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="问题描述" :span="2">{{ feedbackOrder.orderDesc || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="维修处置结果" :span="2">{{ feedbackOrder.repairResult || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <el-form-item label="治理反馈" prop="rectifyFeedback">
+          <el-input v-model="feedbackForm.rectifyFeedback" type="textarea" :rows="5" placeholder="请输入治理过程、处理结果及现场复核情况" />
+        </el-form-item>
       </el-form>
       <template #footer>
         <div class="dialog-footer">
-          <el-button type="primary" :loading="saving" @click="saveFeedback">保存反馈</el-button>
+          <el-button type="primary" :loading="saving" @click="saveFeedback">提交反馈</el-button>
           <el-button @click="feedbackVisible = false">取 消</el-button>
         </div>
       </template>
     </el-dialog>
+
+    <el-dialog v-model="orderVisible" title="关联工单信息" width="720px" append-to-body destroy-on-close>
+      <el-descriptions v-if="orderData" :column="2" border v-loading="orderLoading">
+        <el-descriptions-item label="工单编号">{{ orderData.orderNo || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="工单状态">
+          <el-tag :type="orderStatusType(orderData.orderStatus)">{{ orderStatusText(orderData.orderStatus) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="设备名称">{{ orderData.deviceName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="设备编码">{{ orderData.deviceCode || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="处置人">{{ orderData.receiveUserName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="核验人">{{ orderData.verifyUserName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="故障发生时间">{{ orderData.faultTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="计划完成时间">{{ orderData.planFinishTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="实际完成时间">{{ orderData.realFinishTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="问题描述" :span="2">{{ orderData.orderDesc || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="维修处置结果" :span="2">{{ orderData.repairResult || '-' }}</el-descriptions-item>
+      </el-descriptions>
+      <el-empty v-else-if="!orderLoading" description="工单信息加载失败" />
+    </el-dialog>
   </div>
 </template>
 
@@ -257,7 +247,9 @@
 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'
+import { getHazardById, getHazardPage, submitHazard, feedbackHazard } from '@/api/pipeNetwork/hazard'
+import { getWorkOrder } from '@/api/pipeNetwork/ops/workOrder'
+import { orderStatusText, orderStatusType } from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
 import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 
 const props = defineProps({
@@ -282,10 +274,12 @@ const queryParams = reactive({
   hazardType: '',
   hazardLevel: '',
   hazardSource: '',
+  district: '',
   hazardStatus: '',
-  // 治理反馈默认展示全部隐患,避免历史数据未补录报送状态时出现空表。
-  // 需要只看已报送数据时,可通过上方筛选项选择“已报送”。
-  isSubmitted: null,
+  // 报送页固定只展示未报送隐患;治理反馈页固定只看已报送数据。
+  isSubmitted: props.mode === 'feedback' ? 1 : 0,
+  // 反馈状态筛选(仅治理反馈页):0-未反馈 1-已反馈
+  feedbackStatus: '',
   timeType: 'report'
 })
 
@@ -293,33 +287,29 @@ const hazardTypeDict = ref([])
 const hazardLevelDict = ref([])
 const hazardStatusDict = ref([])
 const hazardSourceDict = ref([])
+// 所属片区,与字典 drainage_district、DistrictEnum、equipment_base.district 同口径(0城南/1城北/2太常)
+const districtDict = 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 feedbackOrder = ref(null)
+const feedbackOrderLoading = ref(false)
+const orderVisible = ref(false)
+const orderLoading = ref(false)
+const orderData = ref(null)
+const detailOrder = ref(null)
 
 const feedbackForm = reactive({
   hazardNo: '',
   hazardDesc: '',
-  verifyPerson: '',
-  verifyTime: '',
-  rectifyPerson: '',
-  rectifyDeadline: '',
-  hazardStatus: '',
-  rectifyPlan: '',
-  rectifyFeedback: '',
-  remark: ''
+  rectifyFeedback: ''
 })
 
 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' }]
 }
 
@@ -332,13 +322,15 @@ function isSubmitted(row) {
   return Number(row?.isSubmitted) === 1
 }
 
+// 反馈状态判定:治理反馈内容已填写即视为已反馈(与隐患一张图口径一致)
+function hasFeedback(row) {
+  return !!(row?.rectifyFeedback && String(row.rectifyFeedback).trim())
+}
+
 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'
-  // Element Plus 没有 primary 类型,空串回落成默认色
-  if (label.includes('一般') || String(value) === '2') return ''
-  return 'info'
+  // 与报警等级口径一致:1 一级(红 danger) / 2 二级(橙 warning) / 3 三级(蓝 默认色) / 4 四级(绿 success)
+  const map = { '1': 'danger', '2': 'warning', '3': '', '4': 'success' }
+  return map[String(value)] || 'info'
 }
 
 function statusTag(value) {
@@ -393,24 +385,32 @@ function buildQuery() {
     params.startDate = dateRange.value[0]
     params.endDate = dateRange.value[1]
   }
-  if (props.mode !== 'submission') {
+  if (props.mode === 'submission') {
+    // 报送页固定只展示未报送数据
+    params.timeType = 'report'
+    params.isSubmitted = 0
+  } else {
+    // 治理反馈页固定只展示已报送数据
     params.timeType = 'report'
+    params.isSubmitted = 1
   }
   return params
 }
 
 async function loadDicts() {
   try {
-    const [typeRes, levelRes, statusRes, sourceRes] = await Promise.all([
+    const [typeRes, levelRes, statusRes, sourceRes, districtRes] = await Promise.all([
       getDicts('hazard_type'),
       getDicts('hazard_level'),
       getDicts('hazard_status'),
-      getDicts('hazard_source')
+      getDicts('hazard_source'),
+      getDicts('drainage_district')
     ])
     hazardTypeDict.value = typeRes.data || []
     hazardLevelDict.value = levelRes.data || []
     hazardStatusDict.value = statusRes.data || []
     hazardSourceDict.value = sourceRes.data || []
+    districtDict.value = districtRes.data || []
   } catch (error) {
     ElMessage.error(error?.message || '隐患字典加载失败')
   }
@@ -441,8 +441,10 @@ function handleReset() {
     hazardType: '',
     hazardLevel: '',
     hazardSource: '',
+    district: '',
     hazardStatus: '',
-    isSubmitted: null,
+    isSubmitted: props.mode === 'feedback' ? 1 : 0,
+    feedbackStatus: '',
     timeType: 'report'
   })
   dateRange.value = []
@@ -460,13 +462,23 @@ async function fetchDetail(row) {
 
 async function handleDetail(row) {
   detailData.value = await fetchDetail(row)
+  detailOrder.value = null
   detailVisible.value = true
+  if (props.mode === 'feedback' && detailData.value?.workOrderId) {
+    try {
+      const response = await getWorkOrder(detailData.value.workOrderId)
+      // 详情接口返回 { order, logs },工单字段在 order 里
+      detailOrder.value = response.data?.order || null
+    } catch (error) {
+      detailOrder.value = null
+    }
+  }
 }
 
 async function handleSubmit(row) {
   try {
     await ElMessageBox.confirm(
-      `确认将隐患“${row.hazardNo}”报送到上级主管单位?报送后将按现有逻辑自动创建工单。`,
+      `确认将隐患“${row.hazardNo}”报送到上级主管单位?报送成功后将自动生成工单并流转至工单系统。`,
       '隐患报送确认',
       { type: 'warning', confirmButtonText: '确认报送', cancelButtonText: '取消' }
     )
@@ -479,28 +491,53 @@ async function handleSubmit(row) {
   }
 }
 
-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())}`
+function handleViewOrder(row) {
+  if (!row?.workOrderId) return
+  orderVisible.value = true
+  orderLoading.value = true
+  orderData.value = null
+  getWorkOrder(row.workOrderId)
+    .then(response => {
+      // 详情接口返回 { order, logs },工单字段在 order 里
+      orderData.value = response.data?.order || null
+    })
+    .catch(error => {
+      ElMessage.error(error?.response?.data?.msg || error?.message || '工单信息加载失败')
+    })
+    .finally(() => {
+      orderLoading.value = false
+    })
 }
 
 async function handleFeedback(row) {
   const record = await fetchDetail(row)
+  if (hasFeedback(record)) {
+    ElMessage.warning('该隐患已反馈,不允许二次修改')
+    return
+  }
   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 || ''
+    rectifyFeedback: ''
   })
   feedbackVisible.value = true
+  // 拉取关联工单,展示在反馈表单内辅助填写
+  feedbackOrder.value = null
+  if (record.workOrderId) {
+    feedbackOrderLoading.value = true
+    getWorkOrder(record.workOrderId)
+      .then(response => {
+        // 详情接口返回 { order, logs },工单字段在 order 里
+        feedbackOrder.value = response.data?.order || null
+      })
+      .catch(() => {
+        feedbackOrder.value = null
+      })
+      .finally(() => {
+        feedbackOrderLoading.value = false
+      })
+  }
 }
 
 async function saveFeedback() {
@@ -508,23 +545,14 @@ async function saveFeedback() {
   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('治理反馈保存成功')
+    // 仅提交反馈内容,反馈后不允许二次修改
+    await feedbackHazard(feedbackRecord.value.hazardId, feedbackForm.rectifyFeedback)
+    ElMessage.success('反馈提交成功')
     feedbackVisible.value = false
     loadData()
   } catch (error) {
     if (error?.fields) return
-    ElMessage.error(error?.response?.data?.msg || error?.message || '治理反馈保存失败')
+    ElMessage.error(error?.response?.data?.msg || error?.message || '治理反馈提交失败')
   } finally {
     saving.value = false
   }
@@ -553,6 +581,25 @@ onMounted(async () => {
   margin-bottom: 20px;
 }
 
+.feedback-order {
+  margin-bottom: 20px;
+}
+
+.feedback-order-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+  margin-bottom: 8px;
+}
+
+.order-tag {
+  margin-right: 8px;
+}
+
+.order-no {
+  color: var(--el-text-color-primary);
+}
+
 .dialog-footer {
   display: flex;
   align-items: center;

+ 57 - 108
src/views/subSystem/hidden/HazardStatistics.vue

@@ -25,28 +25,28 @@
       </el-form>
     </el-card>
 
-    <!-- 中部:隐患类型分布(多折线) + 隐患整改统计(分组柱状) -->
+    <!-- 中部:隐患类型分布(多折线,整行) -->
     <el-row :gutter="16" class="chart-row">
-      <el-col :span="12">
+      <el-col :span="24">
         <el-card class="chart-card">
           <template #header><span>隐患类型分布</span></template>
           <div ref="chartTypeRef" class="chart" v-loading="typeLoading"></div>
         </el-card>
       </el-col>
+    </el-row>
+
+    <!-- 下部:隐患整改统计(饼图) + 各区域隐患统计(饼图) -->
+    <el-row :gutter="16" class="chart-row">
       <el-col :span="12">
         <el-card class="chart-card">
           <template #header><span>隐患整改统计</span></template>
-          <div ref="chartStatusRef" class="chart" v-loading="statusLoading"></div>
+          <div ref="chartRectifyRef" class="chart" v-loading="rectifyLoading"></div>
         </el-card>
       </el-col>
-    </el-row>
-
-    <!-- 底部:各区域隐患统计 -->
-    <el-row :gutter="16" class="chart-row">
-      <el-col :span="24">
+      <el-col :span="12">
         <el-card class="chart-card">
           <template #header><span>各区域隐患统计</span></template>
-          <div ref="chartRegionRef" class="chart" v-loading="regionLoading"></div>
+          <div ref="chartDistrictRef" class="chart" v-loading="districtLoading"></div>
         </el-card>
       </el-col>
     </el-row>
@@ -57,20 +57,19 @@
 import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
 import {
   getHazardStatisticsTypeTimeline,
-  getHazardStatisticsStatusTimeline,
-  getHazardStatisticsByRegion
+  getHazardStatisticsRectifyPie,
+  getHazardStatisticsDistrictPie
 } from '@/api/pipeNetwork/hazard'
 import { getDicts } from '@/api/system/dict/data'
 
 const chartTypeRef = ref(null)
-const chartStatusRef = ref(null)
-const chartRegionRef = ref(null)
+const chartRectifyRef = ref(null)
+const chartDistrictRef = ref(null)
 const typeLoading = ref(false)
-const statusLoading = ref(false)
-const regionLoading = ref(false)
+const rectifyLoading = ref(false)
+const districtLoading = ref(false)
 
 const hazardTypeDict = ref([])
-const hazardStatusDict = ref([])
 const dateRange = ref([])
 const activeShortcut = ref(7)
 
@@ -95,14 +94,9 @@ function setDateRange(days) {
 dateRange.value = daysAgo(7)
 
 const TYPE_COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4']
-const STATUS_BAR_COLORS = [
-  ['#fac858', '#d4a017'],  // 待整改 橙
-  ['#409eff', '#1a6dd4'],  // 整改中 蓝
-  ['#52c41a', '#2d8c0e'],  // 已整改 绿
-  ['#ee6666', '#c93737'],
-  ['#73c0de', '#4a9ec9'],
-  ['#9a60b4', '#7a3d9a']
-]
+const PIE_COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4']
+// 未整改 橙红 / 已整改 绿
+const RECTIFY_COLORS = { '未整改': '#ee6666', '已整改': '#52c41a' }
 
 function getDictLabel(dict, value) {
   if (!dict || !dict.length) return value
@@ -119,12 +113,8 @@ function getDateParams() {
 
 async function loadDicts() {
   try {
-    const [typeRes, statusRes] = await Promise.all([
-      getDicts('hazard_type'),
-      getDicts('hazard_status')
-    ])
+    const typeRes = await getDicts('hazard_type')
     hazardTypeDict.value = typeRes.data || []
-    hazardStatusDict.value = statusRes.data || []
   } catch (e) {
     console.error('加载字典失败', e)
   }
@@ -201,118 +191,77 @@ async function initTypeChart() {
   }
 }
 
-async function initStatusChart() {
-  statusLoading.value = true
+async function initRectifyChart() {
+  rectifyLoading.value = true
   try {
     await nextTick()
     const echartsModule = await import('echarts')
     const echarts = echartsModule.default || echartsModule
-    const res = await getHazardStatisticsStatusTimeline(getDateParams())
-    const payload = res.data || {}
-    const categories = payload.categories || []
-    const rawSeries = payload.series || []
-    if (!chartStatusRef.value) return
+    const res = await getHazardStatisticsRectifyPie(getDateParams())
+    const data = res.data || []
+    if (!chartRectifyRef.value) return
 
-    const c = echarts.init(chartStatusRef.value)
+    const c = echarts.init(chartRectifyRef.value)
     c.setOption({
-      tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-      legend: {
-        data: rawSeries.map(s => getDictLabel(hazardStatusDict, s.name)),
-        bottom: 0,
-        type: 'scroll'
-      },
-      grid: { left: '3%', right: '4%', top: 20, bottom: 40, containLabel: true },
-      xAxis: {
-        type: 'category',
-        data: categories,
-        axisLine: { lineStyle: { color: '#ccc' } },
-        axisTick: { show: false }
-      },
-      yAxis: {
-        type: 'value',
-        axisLine: { show: false },
-        axisTick: { show: false },
-        splitLine: { lineStyle: { color: '#f0f0f0' } }
-      },
-      series: rawSeries.map((s, i) => {
-        const [c1, c2] = STATUS_BAR_COLORS[i % STATUS_BAR_COLORS.length]
-        return {
-          type: 'bar',
-          name: getDictLabel(hazardStatusDict, s.name),
-          data: s.data || [],
-          barWidth: 16,
-          itemStyle: {
-            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-              { offset: 0, color: c1 },
-              { offset: 1, color: c2 }
-            ]),
-            borderRadius: [4, 4, 0, 0]
-          }
-        }
-      })
+      tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
+      legend: { bottom: 0 },
+      color: data.map(d => RECTIFY_COLORS[d.name]).filter(Boolean),
+      series: [{
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '45%'],
+        avoidLabelOverlap: true,
+        itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
+        label: { show: true, formatter: '{b}\n{c} ({d}%)' },
+        data
+      }]
     })
     registerChart(c)
   } catch (e) {
     console.error('隐患整改统计图表加载失败', e)
   } finally {
-    statusLoading.value = false
+    rectifyLoading.value = false
   }
 }
 
-async function initRegionChart() {
-  regionLoading.value = true
+async function initDistrictChart() {
+  districtLoading.value = true
   try {
     await nextTick()
     const echartsModule = await import('echarts')
     const echarts = echartsModule.default || echartsModule
-    const res = await getHazardStatisticsByRegion(getDateParams())
-    const rawData = (res.data || []).sort((a, b) => b.value - a.value)
-    if (!chartRegionRef.value) return
+    const res = await getHazardStatisticsDistrictPie(getDateParams())
+    const data = res.data || []
+    if (!chartDistrictRef.value) return
 
-    const c = echarts.init(chartRegionRef.value)
+    const c = echarts.init(chartDistrictRef.value)
     c.setOption({
-      tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-      grid: { left: '3%', right: '4%', top: 20, bottom: 10, containLabel: true },
-      xAxis: {
-        type: 'category',
-        data: rawData.map(d => d.name),
-        axisLine: { lineStyle: { color: '#ccc' } },
-        axisTick: { show: false }
-      },
-      yAxis: {
-        type: 'value',
-        axisLine: { show: false },
-        axisTick: { show: false },
-        splitLine: { lineStyle: { color: '#f0f0f0' } }
-      },
+      color: PIE_COLORS,
+      tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
+      legend: { bottom: 0, type: 'scroll' },
       series: [{
-        type: 'bar',
-        data: rawData.map(d => ({
-          value: d.value,
-          itemStyle: {
-            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-              { offset: 0, color: '#409eff' }, { offset: 1, color: '#a0d2ff' }
-            ]),
-            borderRadius: [4, 4, 0, 0]
-          }
-        })),
-        barWidth: 32,
-        label: { show: true, position: 'top', color: '#333' }
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '45%'],
+        avoidLabelOverlap: true,
+        itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
+        label: { show: true, formatter: '{b}\n{c} ({d}%)' },
+        data
       }]
     })
     registerChart(c)
   } catch (e) {
     console.error('各区域隐患统计图表加载失败', e)
   } finally {
-    regionLoading.value = false
+    districtLoading.value = false
   }
 }
 
 function loadAllCharts() {
   disposeCharts()
   initTypeChart()
-  initStatusChart()
-  initRegionChart()
+  initRectifyChart()
+  initDistrictChart()
 }
 
 function handleSearch() {

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

@@ -0,0 +1,7 @@
+<template>
+  <HazardLedger />
+</template>
+
+<script setup>
+import HazardLedger from './HazardLedger.vue'
+</script>

+ 1 - 1
src/views/subSystem/manholeCover/caseHandling/circulation.vue

@@ -726,7 +726,7 @@ onActivated(() => {
 .flow-desc { font-size: 13px; color: #606266; line-height: 1.7; margin-top: 6px; white-space: pre-wrap; }
 
 /* 地图图例 */
-.map-legend { position: absolute; left: 10px; bottom: 10px; background: rgba(255, 255, 255, 0.94); border: 1px solid #dcdfe6; border-radius: 4px; padding: 7px 9px; font-size: 12px; color: #606266; display: flex; flex-direction: column; gap: 5px; z-index: 5; }
+.map-legend { position: absolute; left: 10px; bottom: 10px; background: rgba(255, 255, 255, 0.94); border: 1px solid #dcdfe6; border-radius: 4px; padding: 7px 9px; font-size: 12px; color: #606266; display: flex; flex-direction: column; gap: 5px; z-index: 900; }
 .map-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
 
 .empty { padding: 44px 0; text-align: center; color: #909399; font-size: 13px; }