|
|
@@ -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>
|