Pārlūkot izejas kodu

重构-窨井子系统--故障管理

zengy 1 dienu atpakaļ
vecāks
revīzija
1a2597cdde

+ 2 - 61
src/api/pipeNetwork/basic.js

@@ -83,63 +83,7 @@ export function getManholeRelDeviceData(equipmentId) {
 export function getManholeLayerData(data = {}) {
   return request({ url: '/manhole/layer/getManholeLayerData', method: 'post', data })
 }
-/**
- * 窨井盖实时监测 - 获取设备报警监控列表
- *
- * 业务逻辑:
- *   查询所有窨井类型(equipment_type_id IN (9,10))设备的 jg_device_data 最新一条记录
- *   根据以下字段判定告警等级:
- *     - 倾斜角度: tilt_angle > angle_alarm_threshold → 严重告警
- *     - 水浸:     water_infiltration_alarm_status = 1 → 严重告警
- *     - 水位:     water_level_alarm_status = 1       → 严重告警
- *     - 低电量:   battery_level <= 20               → 一般告警
- *     - 弱信号:   signal_strength <= 20              → 一般告警
- *
- * 后端接口: POST /manhole/device/getAlarmMonitorList
- * 请求参数: { keyword?: string }  // keyword 模糊匹配 equipment_code / equipment_name
- * 返回结构:
- * {
- *   code: 200,
- *   data: {
- *     devices: [{
- *       equipmentId: string,
- *       equipmentCode: string,
- *       equipmentName: string,
- *       equipmentLocation: string,
- *       equipmentTypeName: string,
- *       manholeData: {                  // jg_device_data 最新一条记录
- *         id: string,
- *         batteryLevel: string,          // 电量百分比
- *         signalStrength: string,        // 信号强度百分比
- *         temperatureValue: string,      // 温度
- *         tiltAngle: string,             // 当前倾斜角度
- *         angleAlarmThreshold: string,   // 倾斜报警阈值
- *         alarmStatus: string,           // 综合报警状态 0=正常 1=报警
- *         waterInfiltrationAlarmStatus: string, // 水浸报警 0=正常 1=报警
- *         waterLevelAlarmStatus: string,        // 水位报警 0=正常 1=报警
- *         uploadTime: string,            // 数据上传时间
- *         createTime: string             // 记录创建时间
- *       },
- *       equipmentStatus: {              // 设备运维状态
- *         currentStatus: number,         // 1=在用 3=维修
- *         alarmStatus: number,           // 0=正常 1=报警
- *         onlineStatus: number           // 0=离线 1=在线
- *       }
- *     }],
- *     statistics: {
- *       total: number,                  // 设备总数
- *       critical: number,               // 严重告警数
- *       warning: number,                // 一般告警数
- *       normal: number,                 // 正常设备数
- *       onlineCount: number,            // 在线设备数
- *       avgBattery: number              // 平均电量
- *     }
- *   }
- * }
- */
-export function getManholeAlarmMonitorList(data = {}) {
-  return request({ url: '/manhole/device/getAlarmMonitorList', method: 'post', data })
-}
+
 
 export function getManholeTerminalFaultPage(data) {
   return request({ url: '/manhole/terminalFault/findByPage', method: 'post', data })
@@ -173,10 +117,7 @@ export function addManholeWorkOrder(data) {
 export function getManholeDataPage(params) {
   return request({ url: '/api/manhole/data/page', method: 'get', params })
 }
-// 修改设备运维状态(currentStatus:1-在用 2-闲置 3-维修 4-报废 5-待入库)
-export function updateManholeDeviceStatus(data) {
-  return request({ url: '/manhole/device/updateDeviceStatus', method: 'post', data })
-}
+
 
 // ============ 运维数据统计 ============
 // 运维看板统计数据:一次返回KPI指标/异常终端趋势/处置率延期率趋势/问题类型分布/区域排行/异常终端列表

+ 366 - 673
src/views/subSystem/manholeCover/operation/faultMana.vue

@@ -1,774 +1,467 @@
-<!--故障管理-->
+<!--窨井设备运维中心:以工单为数据源展示待派单/处置中,地图上按设备标注并查看处置日志-->
 <template>
-  <div class="device-maintenance">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Monitor /></el-icon>
-        <span class="title">智能终端设备运维中心</span>
-        <el-tag type="danger" effect="dark">故障设备 {{ faultCount }}</el-tag>
-      </div>
-      <div class="header-right">
-        <el-input
-            v-model="searchKeyword"
-            placeholder="搜索设备编号/名称/位置"
-            clearable
-            :prefix-icon="Search"
-            style="width: 260px"
-        />
-        <el-select v-model="faultTypeFilter" placeholder="故障类型" clearable style="width: 140px">
-          <el-option label="倾斜超标" value="tilt" />
-          <el-option label="水位超限" value="waterLevel" />
-          <el-option label="水浸报警" value="waterInfiltration" />
-          <el-option label="通讯故障" value="offline" />
-          <el-option label="电池低电量" value="battery" />
-          <el-option label="信号弱" value="signal" />
-        </el-select>
-        <el-button type="primary" :icon="Refresh" @click="refreshData" :loading="loading">刷新</el-button>
-      </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-cards">
-      <div class="stat-card critical">
-        <div class="stat-value">{{ criticalFaultCount }}</div>
-        <div class="stat-label">严重故障</div>
-      </div>
-      <div class="stat-card warning">
-        <div class="stat-value">{{ normalFaultCount }}</div>
-        <div class="stat-label">一般故障</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ processingCount }}</div>
-        <div class="stat-label">处置中</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ completedCount }}</div>
-        <div class="stat-label">正常设备</div>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧地图 + 右侧列表 -->
-    <div class="main-layout">
-      <!-- 左侧GIS地图终端分布 -->
-      <div class="map-panel">
-        <div class="map-header">
-          <span><el-icon><MapLocation /></el-icon> 终端分布地图</span>
-          <div class="map-legend">
-            <span><i class="legend-dot critical-dot"></i>严重故障</span>
-            <span><i class="legend-dot warning-dot"></i>一般故障</span>
-            <span><i class="legend-dot normal-dot"></i>正常</span>
-            <span><i class="legend-dot processing-dot"></i>处置中</span>
-          </div>
-        </div>
-        <div class="map-container" v-loading="loading">
-          <div id="faultManaMap" class="bmap-container"></div>
-        </div>
-      </div>
-
-      <!-- 右侧设备列表 -->
-      <div class="device-list-panel">
-        <div class="list-header">
-          <span>故障设备列表</span>
-          <el-radio-group v-model="listFilter" size="small">
-            <el-radio-button label="all">全部故障</el-radio-button>
-            <el-radio-button label="pending">待处置</el-radio-button>
-            <el-radio-button label="processing">处置中</el-radio-button>
-          </el-radio-group>
-        </div>
-        <div class="device-list">
-          <div
-              v-for="device in paginatedDevices"
-              :key="device.id"
-              class="device-item"
-              :class="{
-              selected: selectedDevice?.id === device.id,
-              critical: device.isCritical,
-              warning: !device.isCritical
-            }"
-              @click="selectDevice(device)"
-          >
-            <div class="device-header">
-              <span class="device-code">{{ device.code }}</span>
-              <el-tag :type="device.isCritical ? 'danger' : 'warning'" size="small">
-                {{ device.isCritical ? '严重' : '一般' }}
-              </el-tag>
-            </div>
-            <div class="device-location">
-              <el-icon><Location /></el-icon> {{ device.locationShort }}
-            </div>
-            <div class="device-fault">{{ device.faultTypeName }}</div>
-            <div class="device-status">
-              <el-tag :type="getStatusTag(device)" size="small">{{ getStatusText(device) }}</el-tag>
-            </div>
-          </div>
-          <el-empty v-if="!paginatedDevices.length && !loading" description="暂无故障设备" :image-size="80" />
-        </div>
-        <div class="pagination-area">
-          <el-pagination
-              small
-              layout="prev, pager, next"
-              :total="filteredDevices.length"
-              :page-size="pageSize"
-              v-model:current-page="currentPage"
-          />
+  <div class="gis-page">
+    <div class="page-head">
+      <div class="page-head-titles">
+        <div class="page-title">
+          <el-icon class="page-title-icon"><MapLocation /></el-icon>
+          <span>设备故障情况</span>
         </div>
       </div>
+      <el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
     </div>
 
-    <!-- 设备详情与处置抽屉 -->
-    <el-drawer v-model="detailDrawerVisible" :title="`设备运维 - ${selectedDevice?.code}`" size="500px" direction="rtl" class="detail-drawer">
-      <div v-if="selectedDevice" class="device-detail">
-        <!-- 设备基本信息 -->
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="设备编号">{{ selectedDevice.code }}</el-descriptions-item>
-          <el-descriptions-item label="设备名称">{{ selectedDevice.name }}</el-descriptions-item>
-          <el-descriptions-item label="设备类型">{{ selectedDevice.deviceTypeName }}</el-descriptions-item>
-          <el-descriptions-item label="所属片区">{{ districtLabel(selectedDevice.district) || '—' }}</el-descriptions-item>
-          <el-descriptions-item label="详细地址">{{ selectedDevice.location }}</el-descriptions-item>
-          <el-descriptions-item label="经纬度">{{ selectedDevice.lng ?? '—' }}, {{ selectedDevice.lat ?? '—' }}</el-descriptions-item>
-          <el-descriptions-item label="故障类型">{{ selectedDevice.faultTypeName }}</el-descriptions-item>
-          <el-descriptions-item label="故障描述">{{ selectedDevice.faultDesc }}</el-descriptions-item>
-          <el-descriptions-item label="故障时间">{{ selectedDevice.abnormalTime || '—' }}</el-descriptions-item>
-          <el-descriptions-item label="当前状态">
-            <el-tag :type="getStatusTag(selectedDevice)">{{ getStatusText(selectedDevice) }}</el-tag>
-          </el-descriptions-item>
-        </el-descriptions>
-
-        <!-- 处置操作区 -->
-        <div class="operation-section" v-if="!selectedDevice.scrapped">
-          <div class="section-title">
-            <span><el-icon><Tools /></el-icon> 现场处置操作</span>
-          </div>
-
-          <!-- 处置操作按钮组:上报工单 + 报废 -->
-          <div class="operation-buttons">
-            <el-button type="primary" @click="openWorkOrderDialog" :disabled="selectedDevice.workOrderSubmitted">上报工单</el-button>
-            <el-button type="danger" @click="openScrapDialog">报废</el-button>
-          </div>
-          <div v-if="selectedDevice.workOrderSubmitted" class="order-hint">该设备已有未办结工单,无需重复上报</div>
+    <!-- 统计指标 -->
+    <div class="kpi-row">
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon pending"><el-icon><Tickets /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">待派单故障</div>
+          <div class="kpi-value">{{ pendingCount }}</div>
         </div>
-
-        <!-- 已报废状态展示 -->
-        <div class="completed-section" v-else>
-          <el-alert title="设备已报废" type="danger" :closable="false" show-icon>
-            <div>报废时间: {{ selectedDevice.scrapTime || '—' }}</div>
-            <div>报废说明: {{ selectedDevice.scrapRemark || '—' }}</div>
-          </el-alert>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon processing"><el-icon><Tools /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">处置中故障</div>
+          <div class="kpi-value">{{ processingCount }}</div>
         </div>
+      </el-card>
+    </div>
 
-        <!-- 历史处置记录(工单处理记录) -->
-        <div class="history-section">
-          <div class="section-title"><el-icon><Document /></el-icon> 历史处置记录(工单)</div>
-          <el-timeline v-loading="orderLoading">
-            <el-timeline-item
-                v-for="record in orderRecords"
-                :key="record.orderId"
-                :timestamp="record.createTime"
-                :type="getOrderTimelineType(record.orderStatus)"
-            >
-              <div class="order-record">
-                <div class="order-record-header">
-                  <span class="order-no">{{ record.orderNo }}</span>
-                  <el-tag :type="getOrderStatusTag(record.orderStatus)" size="small">{{ getOrderStatusText(record.orderStatus) }}</el-tag>
-                </div>
-                <div class="order-record-desc">{{ record.orderDesc || '—' }}</div>
-              </div>
-            </el-timeline-item>
-            <el-timeline-item v-if="!orderRecords.length && !orderLoading" timestamp="暂无记录" type="info">
-              该设备尚无工单处置记录
-            </el-timeline-item>
-          </el-timeline>
+    <!-- 地图:按工单对应设备经纬度标注 -->
+    <el-card shadow="never" class="map-card">
+      <div class="map-wrap">
+        <GisMapCanvas
+          ref="gisMapRef"
+          :points="mapPoints"
+          cluster-label="设备集合"
+          :center="mapCenter"
+          :zoom="15"
+          @point-click="openDevice($event.equipmentId)"
+          @error="mapError = $event"
+        />
+        <el-alert
+          v-if="mapError"
+          class="map-error"
+          type="warning"
+          show-icon
+          :closable="false"
+          :title="mapError"
+        >
+          <el-button link type="primary" @click="loadAll">重试</el-button>
+        </el-alert>
+        <el-empty
+          v-if="!loading && !mapError && !mapPoints.length"
+          class="map-empty"
+          description="当前没有待处置的窨井设备工单"
+        />
+      </div>
+    </el-card>
+
+    <!-- 设备工单详情:基本信息 + 多工单 tab + 处置日志时间轴 -->
+    <el-dialog
+      title="设备工单详情"
+      v-model="detailVisible"
+      width="760px"
+      append-to-body
+      class="detail-dialog"
+    >
+      <div v-if="selectedDevice" class="dlg-summary">
+        <div class="summary-code">{{ selectedDevice.code }}</div>
+        <div class="summary-name">{{ selectedDevice.name }}</div>
+        <div class="summary-loc">
+          <el-icon><Location /></el-icon> {{ selectedDevice.location }}
         </div>
       </div>
-    </el-drawer>
-
-    <!-- 上报工单对话框 -->
-    <el-dialog v-model="workOrderDialogVisible" title="上报工单" width="460px">
-      <el-form label-width="90px">
-        <el-form-item label="工单优先级">
-          <el-select v-model="workOrderForm.orderLevel" style="width: 100%">
-            <el-option label="紧急" :value="0" />
-            <el-option label="一般" :value="1" />
-            <el-option label="低" :value="2" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="问题描述">
-          <el-input type="textarea" v-model="workOrderForm.orderDesc" :placeholder="workOrderPlaceholder" rows="3" />
-        </el-form-item>
-        <el-form-item label="定位信息">
-          <div class="location-confirm">
-            <el-icon><Location /></el-icon>
-            {{ selectedDevice?.location }}
-          </div>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="workOrderDialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitting" @click="submitWorkOrder">确认上报</el-button>
-      </template>
-    </el-dialog>
 
-    <!-- 报废确认对话框 -->
-    <el-dialog v-model="scrapDialogVisible" title="报废处理" width="420px">
-      <el-form>
-        <el-form-item label="报废原因">
-          <el-input type="textarea" v-model="scrapRemark" placeholder="请填写报废原因" rows="3" />
-        </el-form-item>
-        <el-form-item label="设备确认">
-          <div class="location-confirm">
-            <el-icon><Warning /></el-icon>
-            {{ selectedDevice?.code }} - {{ selectedDevice?.name }}(报废后设备将停用)
+      <el-tabs v-if="deviceOrders.length" v-model="activeOrderId" @tab-change="onTabChange">
+        <el-tab-pane v-for="o in deviceOrders" :key="o.orderId" :name="String(o.orderId)">
+          <template #label>
+            <span>{{ o.orderNo }}</span>
+            <el-tag :type="orderStatusType(o.orderStatus)" size="small" class="tab-status">
+              {{ orderStatusText(o.orderStatus) }}
+            </el-tag>
+          </template>
+        </el-tab-pane>
+      </el-tabs>
+      <el-empty v-else description="该设备暂无未完成工单" />
+
+      <el-timeline v-loading="logsLoading" class="log-timeline">
+        <el-timeline-item
+          v-for="log in orderLogs"
+          :key="log.logId"
+          :timestamp="timeText(log.operTime)"
+        >
+          <div class="log-item">
+            <div class="log-head">
+              <span class="log-type">{{ log.operTypeDesc || '操作' }}</span>
+              <span v-if="log.operUserName" class="log-user">({{ log.operUserName }})</span>
+            </div>
+            <div class="log-desc">{{ log.operDesc || '—' }}</div>
           </div>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="scrapDialogVisible = false">取消</el-button>
-        <el-button type="danger" :loading="submitting" @click="confirmScrap">确认报废</el-button>
-      </template>
+        </el-timeline-item>
+        <el-empty v-if="!orderLogs.length && !logsLoading" description="暂无处置日志" :image-size="70" />
+      </el-timeline>
     </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
-import { Monitor, Search, Refresh, MapLocation, Location, Tools, Document, Warning } from '@element-plus/icons-vue'
+import { ref, computed, onMounted, nextTick } from 'vue'
+import { MapLocation, Refresh, Tickets, Tools, Location } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
-import { getManholeAlarmMonitorList, addManholeWorkOrder, updateManholeDeviceStatus } from '@/api/pipeNetwork/basic'
-import { listWorkOrder } from '@/api/pipeNetwork/ops/workOrder'
-import locationIcon from '@/assets/images/location.png'
-// 台账存 WGS84,百度地图是 BD-09,不转换会偏约 1 公里
-import { convertCoord } from '@/utils/coordTransform'
+import { listWorkOrder, listWorkOrderLog } from '@/api/pipeNetwork/ops/workOrder'
+import { getEquipmentGisPoints } from '@/api/pipeNetwork/basic'
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import {
+  orderStatusText,
+  orderStatusType,
+  timeText
+} from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
+
+// 窨井设备(与菜单 query.rootTypeId=4 一致)
+const ROOT_TYPE_ID = '4'
+// 未完成工单状态:待派单(0) + 处置链路(1,2,3,4,6)
+const ACTIVE_STATUSES = [0, 1, 2, 3, 4, 6]
+// 处置中口径
+const PROCESSING_STATUSES = [1, 2, 3, 4, 6]
 
-// ==================== 常量 ====================
-const TYPE_MAP = { '5': '供水窨井', '9': '排水窨井', '10': '燃气窨井' }
-
-// 所属片区:后端 DistrictEnum(0-城南 1-城北 2-太常)
-const DISTRICT_MAP = { 0: '城南', 1: '城北', 2: '太常' }
-function districtLabel(code) {
-  return code == null ? '' : (DISTRICT_MAP[code] || String(code))
-}
-const LOW_BATTERY_THRESHOLD = 15
-const LOW_SIGNAL_THRESHOLD = 20
-const ORDER_STATUS_MAP = { 0: '待派单', 1: '待接单', 2: '已接单', 3: '处理中', 4: '待验收', 5: '已结案', 6: '延期中', 7: '已取消' }
-
-// ==================== 状态 ====================
 const loading = ref(false)
-const allDevices = ref([])
-
-// 筛选条件
-const searchKeyword = ref('')
-const faultTypeFilter = ref('')
-const listFilter = ref('all')
-const currentPage = ref(1)
-const pageSize = 6
-
-// 选择与详情
-const selectedDevice = ref(null)
-const detailDrawerVisible = ref(false)
-const orderRecords = ref([])
-const orderLoading = ref(false)
-
-// 上报工单
-const workOrderDialogVisible = ref(false)
-const workOrderForm = ref({ orderLevel: 1, orderDesc: '' })
-const submitting = ref(false)
-
-// 报废
-const scrapDialogVisible = ref(false)
-const scrapRemark = ref('')
-
-// 百度地图相关
-let mapInstance = null
-const markerMap = new Map()
-const YUANLING_CENTER = { lng: 110.393, lat: 28.452 }
-
-/** 业务坐标(WGS84)→ 百度坐标点 */
-function toBdPoint(lng, lat) {
-  const [bdLng, bdLat] = convertCoord(Number(lng), Number(lat))
-  return new BMapGL.Point(bdLng, bdLat)
-}
-
-// ==================== 设备数据规范化 ====================
-function normalizeDevice(raw) {
-  const md = raw?.manholeData || {}
-  const st = raw?.equipmentStatus || {}
-  const typeId = raw?.equipmentTypeId || ''
-
-  const tiltAngle = parseFloat(md.tiltAngle) || 0
-  const angleThreshold = parseFloat(md.angleAlarmThreshold) || 15
-  const battery = parseFloat(md.batteryLevel) || 0
-  const signal = parseFloat(md.signalStrength) || 0
-  const isOnline = Number(st.onlineStatus) === 1
-  const scrapped = Number(st.currentStatus) === 4
-
-  // 异常判定
-  const tilt = isOnline && tiltAngle > angleThreshold
-  const waterInfiltration = isOnline && `${md.waterInfiltrationAlarmStatus}` === '1'
-  const waterLevel = isOnline && `${md.waterLevelAlarmStatus}` === '1'
-  const lowBattery = battery > 0 && battery <= LOW_BATTERY_THRESHOLD
-  const lowSignal = signal > 0 && signal <= LOW_SIGNAL_THRESHOLD
-  const offline = !isOnline
-
-  // 故障类型列表
-  const faultTypes = []
-  if (tilt) faultTypes.push({ type: 'tilt', name: '倾斜超标' })
-  if (waterLevel) faultTypes.push({ type: 'waterLevel', name: '水位超限' })
-  if (waterInfiltration) faultTypes.push({ type: 'waterInfiltration', name: '水浸报警' })
-  if (offline) faultTypes.push({ type: 'offline', name: '通讯故障' })
-  if (lowBattery) faultTypes.push({ type: 'battery', name: '电池低电量' })
-  if (lowSignal) faultTypes.push({ type: 'signal', name: '信号弱' })
-
-  const hasAlert = faultTypes.length > 0 && !scrapped
-  const isCritical = tilt || waterInfiltration || waterLevel
-
-  // 故障描述
-  const parts = []
-  if (tilt) parts.push(`倾斜角度${tiltAngle}°超过阈值${angleThreshold}°`)
-  if (waterInfiltration) parts.push('水浸报警')
-  if (waterLevel) parts.push('水位超标报警')
-  if (lowBattery) parts.push(`电量仅剩${battery}%`)
-  if (lowSignal) parts.push(`信号强度${signal}%`)
-  if (offline) parts.push('设备离线')
-
-  let elapsedHours = 0
-  const faultTime = md.uploadTime || md.createTime
-  if (faultTime) {
-    const diff = Date.now() - new Date(faultTime).getTime()
-    elapsedHours = Math.max(0, Math.round(diff / (1000 * 60 * 60) * 10) / 10)
+const mapError = ref('')
+const gisMapRef = ref(null)
+
+const allOrders = ref([])
+const gisDeviceMap = ref({})
+const detailVisible = ref(false)
+const selectedDeviceId = ref('')
+const deviceOrders = ref([])
+const activeOrderId = ref('')
+const orderLogs = ref([])
+const logsLoading = ref(false)
+
+const mapCenter = { lng: 110.3965, lat: 28.4638 }
+
+// ==================== 统计 ====================
+const pendingCount = computed(() => allOrders.value.filter(o => Number(o.orderStatus) === 0).length)
+const processingCount = computed(() =>
+  allOrders.value.filter(o => PROCESSING_STATUSES.includes(Number(o.orderStatus))).length
+)
+
+// 设备 -> 工单分组
+const deviceOrdersMap = computed(() => {
+  const map = {}
+  for (const o of allOrders.value) {
+    const id = o.deviceId
+    if (!id) continue
+    ;(map[id] ||= []).push(o)
+  }
+  return map
+})
+
+// ==================== 地图点位 ====================
+const mapPoints = computed(() => {
+  const points = []
+  for (const [deviceId, orders] of Object.entries(deviceOrdersMap.value)) {
+    const g = gisDeviceMap.value[deviceId]
+    if (!g || !Number.isFinite(Number(g.lng)) || !Number.isFinite(Number(g.lat))) continue
+    const sample = orders[0] || {}
+    const hasPending = orders.some(o => Number(o.orderStatus) === 0)
+    points.push({
+      equipmentId: deviceId,
+      lng: Number(g.lng),
+      lat: Number(g.lat),
+      name: g.name || sample.deviceName || '设备',
+      // 有待派单的设备用告警色突出,其余用常规色
+      iconUrl: hasPending ? gisPointStatusIcons.alarm : gisPointStatusIcons.normal
+    })
   }
+  return points
+})
 
+const selectedDevice = computed(() => {
+  const id = selectedDeviceId.value
+  const g = gisDeviceMap.value[id]
+  const orders = deviceOrdersMap.value[id] || []
+  const sample = orders[0] || {}
   return {
-    id: raw?.equipmentId || raw?.equipmentCode || '',
-    code: raw?.equipmentCode || raw?.equipmentId || '',
-    equipmentId: raw?.equipmentId || '',
-    name: raw?.equipmentName || raw?.equipmentCode || '未命名设备',
-    deviceTypeName: TYPE_MAP[typeId] || raw?.equipmentTypeName || '未知类型',
-    district: districtLabel(raw?.district),
-    location: raw?.equipmentLocation || '暂无位置信息',
-    locationShort: (raw?.equipmentLocation || '暂无位置').slice(0, 20),
-    lng: raw?.longitude != null ? Number(raw.longitude) : null,
-    lat: raw?.latitude != null ? Number(raw.latitude) : null,
-    battery, signal, tiltAngle, angleThreshold,
-    status: { tilt, waterInfiltration, waterLevel, lowBattery, lowSignal, offline },
-    faultTypes, hasAlert, isCritical, scrapped,
-    faultTypeName: faultTypes.map(f => f.name).join('、') || '正常',
-    faultDesc: parts.join(';') || '设备状态正常',
-    abnormalTime: faultTime || '',
-    elapsedHours,
-    workOrderSubmitted: !!raw?.workOrderSubmitted,
-    statusText: scrapped ? '已报废' : (raw?.workOrderSubmitted ? '处置中' : '待处置')
+    code: g?.code || sample.deviceCode || id,
+    name: g?.name || sample.deviceName || '未命名设备',
+    location: sample.deviceLocation || '暂无位置信息'
   }
-}
+})
 
 // ==================== 数据加载 ====================
-async function loadData() {
+async function loadAll() {
   loading.value = true
+  mapError.value = ''
   try {
-    const { data } = await getManholeAlarmMonitorList({})
-    const devices = (data?.devices || []).map(normalizeDevice)
-    allDevices.value = devices
+    const [orders, gis] = await Promise.all([loadWorkOrders(), loadGisPoints()])
+    allOrders.value = orders
+    gisDeviceMap.value = gis
     await nextTick()
-    refreshMarkers()
-  } catch (error) {
-    ElMessage.error('设备数据加载失败: ' + (error?.message || error?.msg || '未知错误'))
+    gisMapRef.value?.fitBounds()
+  } catch (e) {
+    mapError.value = '数据加载失败,请点击右上角“刷新”重试'
   } finally {
     loading.value = false
   }
 }
 
-const refreshData = () => { loadData() }
-
-// ==================== 计算属性 ====================
-const faultDevices = computed(() => allDevices.value.filter(d => d.hasAlert))
-const filteredDevices = computed(() => {
-  let list = faultDevices.value
-  if (listFilter.value === 'pending') list = list.filter(d => !d.workOrderSubmitted)
-  else if (listFilter.value === 'processing') list = list.filter(d => d.workOrderSubmitted)
-
-  if (searchKeyword.value) {
-    const kw = searchKeyword.value.toLowerCase()
-    list = list.filter(d =>
-        d.code.toLowerCase().includes(kw) ||
-        d.name.toLowerCase().includes(kw) ||
-        d.location.toLowerCase().includes(kw)
-    )
-  }
-  if (faultTypeFilter.value) {
-    list = list.filter(d => d.faultTypes.some(f => f.type === faultTypeFilter.value))
+// 分页拉全未完成工单(后端单页上限 100)
+async function loadWorkOrders() {
+  const list = []
+  let pageNum = 1
+  const pageSize = 100
+  while (true) {
+    const res = await listWorkOrder({
+      orderStatus: ACTIVE_STATUSES.join(','),
+      rootTypeId: ROOT_TYPE_ID,
+      pageNum,
+      pageSize
+    })
+    const data = res?.data || {}
+    const records = data.records || []
+    list.push(...records)
+    if (records.length < pageSize) break
+    pageNum += 1
   }
   return list
-})
-
-const paginatedDevices = computed(() => {
-  const start = (currentPage.value - 1) * pageSize
-  return filteredDevices.value.slice(start, start + pageSize)
-})
-
-const faultCount = computed(() => faultDevices.value.length)
-const criticalFaultCount = computed(() => faultDevices.value.filter(d => d.isCritical).length)
-const normalFaultCount = computed(() => faultDevices.value.filter(d => !d.isCritical).length)
-const processingCount = computed(() => faultDevices.value.filter(d => d.workOrderSubmitted).length)
-const completedCount = computed(() => allDevices.value.length - faultDevices.value.length)
-
-const workOrderPlaceholder = computed(() => '请填写故障现象、发生时间等信息')
-
-// ==================== 状态展示 ====================
-const getStatusTag = (device) => {
-  if (device.scrapped) return 'danger'
-  if (device.workOrderSubmitted) return 'warning'
-  return 'danger'
-}
-const getStatusText = (device) => {
-  if (device.scrapped) return '已报废'
-  if (device.workOrderSubmitted) return '处置中'
-  return '待处置'
 }
 
-const getOrderStatusTag = (status) => {
-  const map = { 1: 'danger', 2: 'warning', 3: 'warning', 4: 'primary', 5: 'primary', 6: 'success', 7: 'info', 8: 'warning', 9: 'danger' }
-  return map[status] || 'info'
-}
-const getOrderStatusText = (status) => ORDER_STATUS_MAP[status] || '未知'
-const getOrderTimelineType = (status) => {
-  if (Number(status) === 6) return 'success'
-  if (Number(status) === 7 || Number(status) === 9) return 'danger'
-  return 'primary'
-}
-
-// ==================== 选择设备 ====================
-const selectDevice = async (device) => {
-  selectedDevice.value = device
-  detailDrawerVisible.value = true
-  highlightMarker(device.id)
-  if (mapInstance && device.lng != null && device.lat != null) {
-    mapInstance.centerAndZoom(toBdPoint(device.lng, device.lat), 15)
-  }
-  await loadOrderRecords(device)
-}
-
-// ==================== 历史处置记录(工单处理记录) ====================
-async function loadOrderRecords(device) {
-  const deviceId = device?.equipmentId
-  if (!deviceId) {
-    orderRecords.value = []
-    return
-  }
-  orderLoading.value = true
-  orderRecords.value = []
-  try {
-    const { data } = await listWorkOrder({ deviceId, pageNum: 1, pageSize: 20 })
-    orderRecords.value = data?.records || data?.rows || []
-  } catch (error) {
-    ElMessage.error('工单记录加载失败: ' + (error?.message || error?.msg || '未知错误'))
-  } finally {
-    orderLoading.value = false
-  }
-}
-
-// ==================== 上报工单 ====================
-const openWorkOrderDialog = () => {
-  const device = selectedDevice.value
-  workOrderForm.value = {
-    orderLevel: device.isCritical ? 0 : 1,
-    orderDesc: `【${device.faultTypeName}】${device.faultDesc}`
-  }
-  workOrderDialogVisible.value = true
-}
-
-const submitWorkOrder = async () => {
-  if (!workOrderForm.value.orderDesc) {
-    ElMessage.warning('请填写问题描述')
-    return
-  }
-  submitting.value = true
-  try {
-    await addManholeWorkOrder({
-      deviceId: selectedDevice.value.equipmentId,
-      orderLevel: workOrderForm.value.orderLevel,
-      orderDesc: workOrderForm.value.orderDesc
-    })
-    ElMessage.success('工单上报成功')
-    workOrderDialogVisible.value = false
-    selectedDevice.value.workOrderSubmitted = true
-    selectedDevice.value.statusText = '处置中'
-    await loadOrderRecords(selectedDevice.value)
-  } catch (error) {
-    ElMessage.error('工单上报失败: ' + (error?.message || error?.msg || '未知错误'))
-  } finally {
-    submitting.value = false
+async function loadGisPoints() {
+  const res = await getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID })
+  const list = res?.data ?? res
+  const map = {}
+  for (const d of Array.isArray(list) ? list : []) {
+    if (d.equipmentId) {
+      map[d.equipmentId] = {
+        lng: d.longitude,
+        lat: d.latitude,
+        code: d.equipmentCode,
+        name: d.equipmentName
+      }
+    }
   }
+  return map
 }
 
-// ==================== 报废处理 ====================
-const openScrapDialog = () => {
-  scrapRemark.value = ''
-  scrapDialogVisible.value = true
+// ==================== 设备详情 / 工单 / 日志 ====================
+function openDevice(deviceId) {
+  if (!deviceId) return
+  selectedDeviceId.value = deviceId
+  deviceOrders.value = deviceOrdersMap.value[deviceId] || []
+  activeOrderId.value = deviceOrders.value[0] ? String(deviceOrders.value[0].orderId) : ''
+  detailVisible.value = true
+  loadLogs(activeOrderId.value)
 }
 
-const confirmScrap = async () => {
-  if (!scrapRemark.value) {
-    ElMessage.warning('请填写报废原因')
-    return
-  }
-  submitting.value = true
-  try {
-    // 报废:修改设备状态 currentStatus=4-报废
-    await updateManholeDeviceStatus({
-      equipmentId: selectedDevice.value.equipmentId,
-      currentStatus: 4
-    })
-    ElMessage.success('设备已报废')
-    scrapDialogVisible.value = false
-    selectedDevice.value.scrapped = true
-    selectedDevice.value.statusText = '已报废'
-    selectedDevice.value.scrapRemark = scrapRemark.value
-    selectedDevice.value.scrapTime = new Date().toLocaleString()
-    // 从故障列表移除并刷新地图
-    await nextTick()
-    refreshMarkers()
-  } catch (error) {
-    ElMessage.error('报废操作失败: ' + (error?.message || error?.msg || '未知错误'))
-  } finally {
-    submitting.value = false
-  }
+function onTabChange(name) {
+  activeOrderId.value = String(name)
+  loadLogs(activeOrderId.value)
 }
 
-// ==================== 百度地图 ====================
-function initMap() {
-  const container = document.getElementById('faultManaMap')
-  if (!container || typeof BMapGL === 'undefined') {
-    console.warn('BMapGL 未加载或容器不存在')
+async function loadLogs(orderId) {
+  if (!orderId) {
+    orderLogs.value = []
     return
   }
+  logsLoading.value = true
   try {
-    mapInstance = new BMapGL.Map('faultManaMap')
-    mapInstance.centerAndZoom(toBdPoint(YUANLING_CENTER.lng, YUANLING_CENTER.lat), 13)
-    mapInstance.enableScrollWheelZoom(true)
+    const res = await listWorkOrderLog(orderId)
+    orderLogs.value = res?.data || []
   } catch (e) {
-    console.error('百度地图初始化失败:', e)
+    ElMessage.error('工单日志加载失败: ' + (e?.message || e?.msg || '未知错误'))
+    orderLogs.value = []
+  } finally {
+    logsLoading.value = false
   }
 }
 
-function getMarkerColor(device) {
-  if (device.scrapped) return '#909399'
-  if (device.workOrderSubmitted) return '#409EFF'
-  if (device.hasAlert) return device.isCritical ? '#F56C6C' : '#E6A23C'
-  return '#67C23A'
-}
-
-function addMapMarker(device) {
-  if (!mapInstance || device.lng == null || device.lat == null) return
-  const color = getMarkerColor(device)
-  const bPoint = toBdPoint(device.lng, device.lat)
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
-      ${device.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:26px;height:26px;display:block;margin:0 auto;"/></div>
-  </div>`
-  const label = new BMapGL.Label(html, {
-    position: bPoint,
-    offset: new BMapGL.Size(-30, -50)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: '10' })
-  label.addEventListener('click', () => selectDevice(device))
-  mapInstance.addOverlay(label)
-  markerMap.set(device.id, label)
-}
-
-function refreshMarkers() {
-  if (!mapInstance) return
-  markerMap.forEach(label => mapInstance.removeOverlay(label))
-  markerMap.clear()
-  allDevices.value.forEach(device => addMapMarker(device))
-}
-
-function highlightMarker(id) {
-  markerMap.forEach((label, key) => {
-    const dom = label.getContentContainer ? label.getContentContainer() : null
-    if (!dom) return
-    const isSelected = key === id
-    dom.style.zIndex = isSelected ? '999' : '10'
-    const title = dom.querySelector('div')
-    if (title) title.style.outline = isSelected ? '2px solid #409EFF' : 'none'
-  })
-}
-
-// ==================== 生命周期 ====================
-const handleResize = () => {
-  mapInstance && mapInstance.resize && mapInstance.resize()
-}
-
-onMounted(async () => {
-  await nextTick()
-  initMap()
-  window.addEventListener('resize', handleResize)
-  loadData()
-})
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', handleResize)
-  if (mapInstance) {
-    mapInstance.clearOverlays && mapInstance.clearOverlays()
-    mapInstance = null
-  }
-  markerMap.clear()
-})
+onMounted(loadAll)
 </script>
 
 <style scoped>
-.device-maintenance {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 14px 20px 20px;
-  box-sizing: border-box;
+.gis-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
 }
 
-.page-header {
-  background: white;
-  border-radius: 12px;
-  padding: 12px 20px;
-  margin-bottom: 14px;
+/* 页头 */
+.page-head {
   display: flex;
+  align-items: center;
   justify-content: space-between;
+  margin-bottom: 20px;
+}
+
+.page-head-titles {
+  display: flex;
   align-items: center;
-  flex-wrap: wrap;
   gap: 12px;
+  min-width: 0;
 }
 
-.header-left { display: flex; align-items: center; gap: 12px; }
-.header-left .title { font-size: 20px; font-weight: 600; }
-.header-right { display: flex; gap: 12px; flex-wrap: wrap; }
-
-.stats-cards {
+.page-title {
   display: flex;
-  gap: 14px;
-  margin-bottom: 14px;
-  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
 }
 
-.stat-card {
-  background: white;
-  border-radius: 12px;
-  padding: 10px 22px;
-  text-align: center;
-  min-width: 108px;
-  flex: 1;
-  max-width: 220px;
-}
-.stat-card.critical .stat-value { color: #f56c6c; }
-.stat-card.warning .stat-value { color: #e6a23c; }
-.stat-value { font-size: 24px; font-weight: bold; line-height: 1.3; }
-.stat-label { font-size: 13px; color: #606266; margin-top: 2px; }
-
-.main-layout {
+.page-title-icon {
+  color: #014db2;
+  font-size: 22px;
+}
+
+/* KPI */
+.kpi-row {
   display: flex;
-  gap: 14px;
-  height: calc(100vh - 236px);
-  min-height: 520px;
+  gap: 16px;
+  margin-bottom: 20px;
 }
 
-.map-panel {
-  flex: 1;
+.kpi {
+  flex: 1 1 0;
   min-width: 0;
-  background: white;
   border-radius: 12px;
+}
+
+.kpi :deep(.el-card__body) {
   display: flex;
-  flex-direction: column;
-  overflow: hidden;
+  align-items: center;
+  gap: 16px;
+  padding: 20px;
 }
 
-.map-header {
-  padding: 12px 16px;
-  border-bottom: 1px solid #e9ecef;
+.kpi-icon {
   display: flex;
-  justify-content: space-between;
   align-items: center;
-  font-weight: 600;
-  font-size: 14px;
-  flex-shrink: 0;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 44px;
+  border-radius: 10px;
+  font-size: 22px;
 }
 
-.map-legend { display: flex; gap: 16px; font-size: 12px; }
-.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 10px; margin-right: 4px; }
-.critical-dot { background: #f56c6c; }
-.warning-dot { background: #e6a23c; }
-.normal-dot { background: #67c23a; }
-.processing-dot { background: #409eff; }
+.kpi-icon.pending { background: #fdecec; color: #ef4444; }
+.kpi-icon.processing { background: #e7f8f1; color: #10b981; }
 
-.map-container {
-  flex: 1;
-  position: relative;
-  min-height: 0;
+.kpi-label {
+  font-size: 13px;
+  font-weight: 400;
+  color: #6b7280;
 }
-.bmap-container { width: 100%; height: 100%; }
 
-.device-list-panel {
-  width: 360px;
-  flex-shrink: 0;
-  background: white;
+.kpi-value {
+  margin-top: 6px;
+  font-size: 28px;
+  font-weight: 700;
+  line-height: 1;
+  color: #0a1628;
+}
+
+/* 地图 */
+.map-card {
   border-radius: 12px;
-  display: flex;
-  flex-direction: column;
+}
+
+.map-card :deep(.el-card__body) {
+  padding: 12px;
+}
+
+.map-wrap {
+  position: relative;
+  width: 100%;
+  height: calc(100vh - 340px);
+  min-height: 480px;
+  border-radius: 8px;
   overflow: hidden;
 }
 
-.list-header {
-  padding: 12px 16px;
-  border-bottom: 1px solid #e9ecef;
+.map-error {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  width: 360px;
+  z-index: 2;
+}
+
+.map-empty {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 2;
+}
+
+/* 详情弹窗 */
+.dlg-summary {
   display: flex;
-  justify-content: space-between;
   align-items: center;
-  font-weight: 600;
-  font-size: 14px;
-  flex-shrink: 0;
+  gap: 12px;
+  flex-wrap: wrap;
+  padding: 14px;
+  border-radius: 8px;
+  background: #f5f9ff;
 }
 
-.device-list {
-  flex: 1;
-  overflow-y: auto;
-  padding: 10px;
-  max-height: none;
+.summary-code {
+  font-size: 12px;
+  font-weight: 400;
+  color: #6b7280;
 }
 
-.device-item {
-  padding: 10px 12px;
-  margin-bottom: 8px;
-  border-radius: 10px;
-  background: #fafbfc;
-  border: 1px solid #e9ecef;
-  cursor: pointer;
-  transition: all 0.2s;
+.summary-name {
+  font-size: 15px;
+  font-weight: 500;
+  color: #014db2;
 }
 
-.device-item.selected { background: #ecf5ff; border-color: #409eff; }
-.device-item.critical { border-left: 3px solid #f56c6c; }
-.device-item.warning { border-left: 3px solid #e6a23c; }
+.summary-loc {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin-left: auto;
+  font-size: 13px;
+  color: #4d667d;
+}
 
-.device-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
-.device-code { font-weight: 600; }
-.device-location { font-size: 12px; color: #6c757d; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
-.device-fault { font-size: 12px; color: #f56c6c; margin-bottom: 4px; }
+.tab-status {
+  margin-left: 6px;
+}
 
-.device-detail { padding: 8px; }
-.section-title {
-  font-weight: 600;
-  font-size: 15px;
-  margin: 20px 0 12px 0;
+.log-timeline {
+  margin-top: 16px;
+  padding-left: 4px;
+}
+
+.log-item {
+  padding: 4px 0;
+}
+
+.log-head {
   display: flex;
-  justify-content: space-between;
   align-items: center;
+  gap: 4px;
+  font-weight: 600;
+  font-size: 13px;
 }
 
-.operation-buttons { display: flex; gap: 12px; margin: 16px 0; }
-.order-hint { font-size: 12px; color: #e6a23c; margin-bottom: 8px; }
-.location-confirm { background: #f5f7fa; padding: 8px; border-radius: 8px; display: flex; align-items: center; gap: 8px; }
-
-.order-record { padding: 4px 0; }
-.order-record-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
-.order-no { font-weight: 600; font-size: 13px; }
-.order-record-desc { font-size: 12px; color: #606266; }
+.log-user {
+  font-weight: 400;
+  color: #6b7280;
+}
 
-.pagination-area { padding: 10px; text-align: center; border-top: 1px solid #eee; flex-shrink: 0; }
+.log-desc {
+  font-size: 12px;
+  color: #606266;
+  margin-top: 2px;
+  white-space: pre-wrap;
+}
 </style>