This commit is contained in:
xiaozhiyong
2026-06-11 17:09:09 +08:00
parent 565cd2921a
commit abe40c5ae9

View File

@@ -1,12 +1,12 @@
<template>
<div class="project-overview">
<div class="main-panel">
<div class="top-panel">
<!-- 地图区域 -->
<div class="overview-card">
<div class="card-header">
<div class="tabs">
<div :class="['tab', { active: deviceTab === 'device' }]" @click="deviceTab = 'device'">设备概况</div>
<div :class="['tab', { active: deviceTab === 'project' }]" @click="deviceTab = 'project'">项目概况</div>
<!-- <div :class="['tab', { active: deviceTab === 'project' }]" @click="deviceTab = 'project'">项目概况</div> -->
</div>
<el-icon class="fullscreen-icon" @click="toggleMapFullscreen"><FullScreen /></el-icon>
</div>
@@ -15,159 +15,369 @@
</div>
</div>
<!-- 警情列表 -->
<div class="alarm-list">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
警情列表
<!-- 警情情况右侧 -->
<div class="alarm-section">
<div class="alarm-title">所属项目</div>
<div class="alarm-card special">
<div class="project-info">
<div class="project-icon">
<el-icon><OfficeBuilding /></el-icon>
</div>
<div class="project-detail">
<div class="project-name">{{ currentProject.name }}</div>
<div class="project-address">
<el-icon><Location /></el-icon>
<span>{{ currentProject.address }}</span>
</div>
</div>
</div>
<el-button class="change-project-btn" type="primary" link @click="openChangeProjectDialog"
>切换项目</el-button
>
</div>
<div class="alarm-title">警情情况</div>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon device-icon">
<el-icon><Monitor /></el-icon>
</div>
<div class="alarm-content">
<div class="alarm-labels">
<span class="label-item">设备总数</span>
<span class="label-item">在线设备数</span>
<span class="label-item">离线设备数</span>
</div>
<div class="alarm-values">
<span class="value-item">{{ alarmSituationSummary.device.total }}</span>
<span class="value-item online">{{ alarmSituationSummary.device.online }}</span>
<span class="value-item offline">{{ alarmSituationSummary.device.offline }}</span>
</div>
</div>
</div>
</div>
<div class="record-tag">智能竖井</div>
<el-table :data="alarmTableData" border stripe class="alarm-table">
<el-table-column align="center" label="序号" prop="index" width="60" />
<el-table-column align="left" label="事件ID" prop="eventId" width="110" />
<el-table-column align="left" label="设备别名" prop="deviceAlias" min-width="150" show-overflow-tooltip />
<el-table-column align="left" label="设备号" prop="deviceNum" width="150" />
<el-table-column align="center" label="线路" prop="line" width="80" />
<el-table-column align="left" label="报警/预警类型" prop="alarmType" width="120" />
<el-table-column align="center" label="报警/预警值(红色)/ 阈值(蓝色)" width="200">
<template #default="scope">
<span v-if="scope.row.alarmValue" class="value-red">{{ scope.row.alarmValue }}</span>
<span v-if="scope.row.alarmValue && scope.row.threshold"> / </span>
<span v-if="scope.row.threshold" class="value-blue">{{ scope.row.threshold }}</span>
<span v-if="!scope.row.alarmValue && !scope.row.threshold">/</span>
</template>
</el-table-column>
<el-table-column align="center" label="耗时" prop="duration" width="80" />
<el-table-column align="center" label="状态" prop="status" width="90">
<template #default="scope">
<el-tag :type="scope.row.status === '已处理' ? 'success' : 'danger'" size="small" effect="light">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="报警/预警时间" prop="alarmTime" width="170" />
<el-table-column align="center" label="动作" width="100" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.status === '未处理'" type="primary" link @click="handleProcess(scope.row)">
处理
</el-button>
<el-button v-else type="primary" link @click="handleRecall(scope.row)">撤回</el-button>
</template>
</el-table-column>
</el-table>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon line-icon">
<el-icon><Connection /></el-icon>
</div>
<div class="alarm-content">
<div class="alarm-labels">
<span class="label-item">线路总数</span>
<span class="label-item">在线线路数</span>
<span class="label-item">离线线路数</span>
</div>
<div class="alarm-values">
<span class="value-item">{{ alarmSituationSummary.line.total }}</span>
<span class="value-item online">{{ alarmSituationSummary.line.online }}</span>
<span class="value-item offline">{{ alarmSituationSummary.line.offline }}</span>
</div>
</div>
</div>
</div>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon warn-icon">
<el-icon><Bell /></el-icon>
</div>
<div class="alarm-content alarm-stats-grid">
<div class="alarm-stat">
<span class="alarm-stat-label">本月报警总数</span>
<span class="alarm-stat-value danger">{{ alarmSituationSummary.alarm.currentMonthTotal }}</span>
</div>
<div class="alarm-stat">
<span class="alarm-stat-label">昨日报警总数</span>
<span class="alarm-stat-value danger">{{ alarmSituationSummary.alarm.yesterdayTotal }}</span>
</div>
<div class="alarm-stat">
<span class="alarm-stat-label">今日报警总数</span>
<span class="alarm-stat-value danger">{{ alarmSituationSummary.alarm.todayTotal }}</span>
</div>
<div class="alarm-stat">
<span class="alarm-stat-label">今日报警设备数</span>
<span class="alarm-stat-value danger">{{ alarmSituationSummary.alarm.todayDeviceCount }}</span>
</div>
</div>
</div>
</div>
<!-- <div class="alarm-card">
<div class="alarm-row process-row">
<div class="process-stat">
<div class="process-ring green">
<span class="process-ring-value">0.18%</span>
</div>
<div class="process-label">本月已处理报警数</div>
<div class="process-sub-value processed">35</div>
</div>
<div class="process-divider"></div>
<div class="process-stat">
<div class="process-ring red">
<span class="process-ring-value">99.82%</span>
</div>
<div class="process-label">本月未处理报警数</div>
<div class="process-sub-value unprocessed">19606</div>
</div>
</div>
</div> -->
</div>
</div>
<!-- 警情情况右侧 -->
<div class="alarm-section">
<div class="alarm-title">警情情况</div>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon device-icon">
<el-icon><Monitor /></el-icon>
</div>
<div class="alarm-content">
<div class="alarm-labels">
<span class="label-item">设备总数</span>
<span class="label-item">在线设备数</span>
<span class="label-item">离线设备数</span>
</div>
<div class="alarm-values">
<span class="value-item">17</span>
<span class="value-item online">12</span>
<span class="value-item offline">5</span>
</div>
</div>
<!-- 警情列表 -->
<div class="alarm-list">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
警情列表
</div>
</div>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon line-icon">
<el-icon><Connection /></el-icon>
</div>
<div class="alarm-content">
<div class="alarm-labels">
<span class="label-item">线路总数</span>
<span class="label-item">在线线路数</span>
<span class="label-item">离线线路数</span>
</div>
<div class="alarm-values">
<span class="value-item">92</span>
<span class="value-item online">74</span>
<span class="value-item offline">18</span>
</div>
</div>
</div>
</div>
<div class="alarm-card">
<div class="alarm-row">
<div class="alarm-icon warn-icon">
<el-icon><Bell /></el-icon>
</div>
<div class="alarm-content">
<div class="alarm-labels">
<span class="label-item">本月报警总数</span>
<span class="label-item">本月预警总数</span>
<span class="label-item">昨日报警总数</span>
</div>
<div class="alarm-values">
<span class="value-item danger">19641</span>
<span class="value-item warning">208</span>
<span class="value-item danger">25</span>
</div>
<div class="alarm-labels second-row">
<span class="label-item">今日报警总数</span>
<span class="label-item">今日报警设备数</span>
</div>
<div class="alarm-values two-cols">
<span class="value-item danger">13</span>
<span class="value-item danger">5</span>
</div>
</div>
</div>
</div>
<div class="alarm-card">
<div class="alarm-row process-row">
<div class="process-stat">
<div class="process-ring green">
<span class="process-ring-value">0.18%</span>
</div>
<div class="process-label">本月已处理报警数</div>
<div class="process-sub-value processed">35</div>
</div>
<div class="process-divider"></div>
<div class="process-stat">
<div class="process-ring red">
<span class="process-ring-value">99.82%</span>
</div>
<div class="process-label">本月未处理报警数</div>
<div class="process-sub-value unprocessed">19606</div>
</div>
</div>
</div>
<el-table :data="alarmTableData" border stripe class="alarm-table">
<el-table-column align="center" label="序号" prop="index" width="60" />
<el-table-column align="left" label="事件ID" prop="eventId" width="110" />
<el-table-column align="left" label="设备别名" prop="deviceAlias" min-width="150" show-overflow-tooltip />
<el-table-column align="left" label="设备号" prop="deviceNum" width="150" />
<el-table-column align="center" label="线路" prop="line" width="80" />
<el-table-column align="left" label="报警/预警类型" prop="alarmType" width="120" />
<el-table-column align="center" label="报警/预警值(红色)/ 阈值(蓝色)" width="200">
<template #default="scope">
<span v-if="scope.row.alarmValue" class="value-red">{{ scope.row.alarmValue }}</span>
<span v-if="scope.row.alarmValue && scope.row.threshold"> / </span>
<span v-if="scope.row.threshold" class="value-blue">{{ scope.row.threshold }}</span>
<span v-if="!scope.row.alarmValue && !scope.row.threshold">/</span>
</template>
</el-table-column>
<el-table-column align="center" label="耗时" prop="duration" width="80" />
<el-table-column align="center" label="状态" prop="status" width="90">
<template #default="scope">
<el-tag :type="scope.row.status === '已处理' ? 'success' : 'danger'" size="small" effect="light">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="报警/预警时间" prop="alarmTime" width="170" />
<el-table-column align="center" label="动作" width="100" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.status === '未处理'" type="primary" link @click="handleProcess(scope.row)">
处理
</el-button>
<el-button v-else type="primary" link @click="handleRecall(scope.row)">撤回</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="projectDialogVisible" destroy-on-close title="更换项目" width="480px">
<el-radio-group v-model="selectedProjectId" class="project-radio-group">
<el-radio v-for="item in projectList" :key="item.id" :value="item.id" class="project-radio-item">
<div class="project-option">
<div class="project-option-name">{{ item.name }}</div>
<div class="project-option-address">{{ item.address }}</div>
</div>
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="projectDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmChangeProject">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import * as serve from '@/api/masterStation/project'
import * as equipmentServe from '@/api/masterStation/equipment'
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { FullScreen, Monitor, Connection, Bell } from '@element-plus/icons-vue'
import { FullScreen, Monitor, Connection, Bell, OfficeBuilding, Location } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
defineOptions({
name: 'MasterStationProject'
})
const PROJECT_STORAGE_KEY = 'masterStation_current_project_id'
const MAP_ZOOM = 13
const MAP_ZOOM_OUT = 9
const MAP_STEP_DURATION = 350
const normalizeProject = (item) => ({
id: item.projectId,
name: item.projectName,
address: item.projectAddress,
lng: Number(item.projectLong),
lat: Number(item.projectLat),
projectUser: item.projectUser,
projectPhone: item.projectPhone
})
const projectList = ref([])
const currentProject = ref({ id: null, name: '', address: '', lng: 119.1, lat: 36.7 })
const projectDialogVisible = ref(false)
const selectedProjectId = ref(null)
const alarmSituationSummary = ref({
device: { total: 0, online: 0, offline: 0 },
line: { total: 0, online: 0, offline: 0 },
alarm: { currentMonthTotal: 0, yesterdayTotal: 0, todayTotal: 0, todayDeviceCount: 0 },
preAlarm: { currentMonthTotal: 0 }
})
let mapSwitchToken = 0
const waitMapEvent = (map, eventName, token) => {
return new Promise((resolve) => {
const timer = setTimeout(() => {
map.off(eventName, handler)
resolve()
}, MAP_STEP_DURATION + 400)
const handler = () => {
clearTimeout(timer)
map.off(eventName, handler)
if (token === mapSwitchToken) resolve()
}
map.on(eventName, handler)
})
}
const moveMapToProject = (project) => {
if (!mapInstance.value || !project?.lng || !project?.lat) return
mapInstance.value.setZoomAndCenter(MAP_ZOOM, [project.lng, project.lat], true)
}
const animateMapSwitch = async (fromProject, toProject) => {
const map = mapInstance.value
if (!map || !fromProject?.lng || !fromProject?.lat || !toProject?.lng || !toProject?.lat) return
const token = ++mapSwitchToken
const fromCenter = [fromProject.lng, fromProject.lat]
const toCenter = [toProject.lng, toProject.lat]
map.setZoomAndCenter(MAP_ZOOM, fromCenter, true)
map.setZoom(MAP_ZOOM_OUT, false, MAP_STEP_DURATION)
await waitMapEvent(map, 'zoomend', token)
if (token !== mapSwitchToken) return
hideProjectMarker()
map.panTo(toCenter, MAP_STEP_DURATION)
await waitMapEvent(map, 'moveend', token)
if (token !== mapSwitchToken) return
map.setZoom(MAP_ZOOM, false, MAP_STEP_DURATION)
await waitMapEvent(map, 'zoomend', token)
if (token !== mapSwitchToken) return
updateProjectMarker(toProject)
}
const applyCurrentProject = (project) => {
if (!project) return
currentProject.value = project
selectedProjectId.value = project.id
updateProjectMarker(project)
moveMapToProject(project)
fetchAlarmSituationSummary()
}
const fetchAlarmSituationSummary = () => {
if (!currentProject.value.id) return
serve.getAlarmSituationSummary({ projectId: currentProject.value.id }).then((res) => {
if (res.code !== 0 || !res.data) return
alarmSituationSummary.value = {
device: { total: 0, online: 0, offline: 0, ...res.data.device },
line: { total: 0, online: 0, offline: 0, ...res.data.line },
alarm: {
currentMonthTotal: 0,
yesterdayTotal: 0,
todayTotal: 0,
todayDeviceCount: 0,
...res.data.alarm
},
preAlarm: { currentMonthTotal: 0, ...res.data.preAlarm }
}
})
}
onMounted(async () => {
await getProjectList()
getAlarmRecordListByPage()
nextTick(() => {
initMap()
if (mapWrapperRef.value) {
mapResizeObserver = new ResizeObserver(handleResize)
mapResizeObserver.observe(mapWrapperRef.value)
}
})
window.addEventListener('resize', handleResize)
})
// 获取警情情况
const getAlarmRecordListByPage = () => {
console.log('currentProject.value', currentProject.value)
if (!currentProject.value.id) return
equipmentServe
.getAlarmRecordListByPage({ page: 1, pageSize: 10, projectId: currentProject.value.id })
.then((res) => {
if (res.code !== 0 || !res.data) return
alarmTableData.value = res.data.list
})
}
// 获取项目列表
const getProjectList = () => {
return serve.getProjectList().then((res) => {
if (res.code !== 0 || !res.data?.length) return
const seen = new Set()
projectList.value = res.data
.filter((item) => {
if (seen.has(item.projectId)) return false
seen.add(item.projectId)
return true
})
.map(normalizeProject)
const savedId = Number(localStorage.getItem(PROJECT_STORAGE_KEY))
// console.log('savedId', savedId)
const project = projectList.value.find((item) => item.id === savedId) || projectList.value[0]
// console.log('project', project)
applyCurrentProject(project)
})
}
const openChangeProjectDialog = () => {
selectedProjectId.value = currentProject.value.id
projectDialogVisible.value = true
}
const confirmChangeProject = () => {
const project = projectList.value.find((item) => item.id === selectedProjectId.value)
if (!project) return
if (project.id === currentProject.value.id) {
projectDialogVisible.value = false
return
}
const prevProject = { ...currentProject.value }
currentProject.value = project
selectedProjectId.value = project.id
localStorage.setItem(PROJECT_STORAGE_KEY, String(project.id))
projectDialogVisible.value = false
ElMessage.success(`已切换至:${project.name}`)
fetchAlarmSituationSummary()
nextTick(() => {
animateMapSwitch(prevProject, project)
})
}
const deviceTab = ref('device')
const mapWrapperRef = ref(null)
const mapInstance = ref(null)
const markers = ref([])
let projectMarker = null
let mapResizeObserver = null
let resizeTimer = null
const alarmTableData = ref([
{
@@ -237,33 +447,48 @@
}
])
const mapDevices = [
{ lng: 116.4, lat: 39.9, label: '10', type: 'cluster' },
{ lng: 113.2, lat: 23.1, label: '6', type: 'cluster' },
{ lng: 118.8, lat: 32.0, label: '98CC4D2052AE', subLabel: '98CC4D2052AE', type: 'device' }
]
const createClusterContent = (label) => {
return `
<div class="map-cluster-marker">
<div class="map-cluster-pulse"></div>
<div class="map-cluster-core">${label}</div>
</div>
`
}
const createDeviceContent = (label, subLabel) => {
const createProjectMarkerContent = (name) => {
return `
<div class="map-device-marker">
<div class="map-device-icon">📍</div>
<div class="map-device-label">
<div class="map-device-name">${label}</div>
<div class="map-device-sub">[${subLabel}]</div>
<div class="map-device-name">${name}</div>
</div>
</div>
`
}
const hideProjectMarker = () => {
if (!projectMarker || !mapInstance.value) return
mapInstance.value.remove(projectMarker)
projectMarker = null
}
const updateProjectMarker = (project) => {
if (!mapInstance.value || !project?.lng || !project?.lat) return
const position = [project.lng, project.lat]
if (!projectMarker) {
projectMarker = new AMap.Marker({
position,
content: createProjectMarkerContent(project.name),
offset: new AMap.Pixel(-10, -30),
zIndex: 10
})
mapInstance.value.add(projectMarker)
return
}
projectMarker.setPosition(position)
const content = projectMarker.getContent()
if (content?.querySelector) {
const nameEl = content.querySelector('.map-device-name')
if (nameEl) nameEl.textContent = project.name
} else {
projectMarker.setContent(createProjectMarkerContent(project.name))
}
}
const initMap = () => {
if (typeof AMap === 'undefined') {
console.error('高德地图API未加载')
@@ -271,29 +496,16 @@
}
mapInstance.value = new AMap.Map('projectMap', {
zoom: 5,
center: [116.4074, 39.9042],
zoom: MAP_ZOOM,
center: [currentProject.value.lng, currentProject.value.lat],
mapStyle: 'amap://styles/whitesmoke',
viewMode: '2D',
features: ['bg', 'road', 'building', 'point']
})
mapDevices.forEach((item) => {
const content =
item.type === 'cluster'
? createClusterContent(item.label)
: createDeviceContent(item.label, item.subLabel)
const marker = new AMap.Marker({
position: [item.lng, item.lat],
content,
offset: item.type === 'cluster' ? new AMap.Pixel(-18, -18) : new AMap.Pixel(-10, -30),
zIndex: item.type === 'device' ? 10 : 5
})
mapInstance.value.add(marker)
markers.value.push(marker)
})
if (currentProject.value.id) {
updateProjectMarker(currentProject.value)
}
}
const toggleMapFullscreen = () => {
@@ -317,18 +529,21 @@
}
const handleResize = () => {
mapInstance.value?.resize()
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
mapInstance.value?.resize()
}, 150)
}
onMounted(() => {
nextTick(() => {
initMap()
})
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
clearTimeout(resizeTimer)
window.removeEventListener('resize', handleResize)
mapResizeObserver?.disconnect()
mapResizeObserver = null
if (projectMarker) {
mapInstance.value?.remove(projectMarker)
projectMarker = null
}
if (mapInstance.value) {
mapInstance.value.destroy()
mapInstance.value = null
@@ -341,17 +556,18 @@
background-color: #f5f7fa;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: calc(100vh - 120px);
color: #303133;
}
.main-panel {
flex: 1;
.top-panel {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-width: 0;
min-height: 0;
}
.overview-card,
@@ -364,7 +580,12 @@
}
.overview-card {
flex: 1;
min-width: 0;
min-height: 0;
padding: 12px;
display: flex;
flex-direction: column;
}
.card-header {
@@ -419,7 +640,8 @@
}
.map-container {
height: 360px;
flex: 1;
min-height: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid #ebeef5;
@@ -449,6 +671,103 @@
.alarm-card {
padding: 14px 12px;
&.special {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.project-info {
display: flex;
gap: 12px;
align-items: flex-start;
}
.project-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: linear-gradient(135deg, #409eff, #2777ec);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
flex-shrink: 0;
}
.project-detail {
flex: 1;
min-width: 0;
}
.project-name {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
word-break: break-all;
}
.project-address {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 12px;
color: #909399;
line-height: 1.5;
.el-icon {
margin-top: 2px;
flex-shrink: 0;
}
}
.change-project-btn {
align-self: flex-end;
padding: 0;
}
.project-radio-group {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.project-radio-item {
display: flex;
align-items: flex-start;
height: auto;
margin-right: 0;
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
width: 100%;
&.is-checked {
border-color: #409eff;
background: #ecf5ff;
}
}
.project-option {
margin-left: 8px;
}
.project-option-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.project-option-address {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.alarm-row {
@@ -487,6 +806,41 @@
flex-direction: column;
gap: 6px;
min-width: 0;
&.alarm-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
}
.alarm-stat {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.alarm-stat-label {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.alarm-stat-value {
font-size: 20px;
font-weight: bold;
font-family: 'DIN', 'Arial', sans-serif;
color: #303133;
line-height: 1.2;
&.danger {
color: #f56c6c;
}
&.warning {
color: #e6a23c;
}
}
.alarm-labels {
@@ -650,18 +1004,6 @@
border-radius: 2px;
}
.record-tag {
display: inline-block;
background: #409eff;
color: #ffffff;
font-size: 12px;
padding: 5px 14px;
border-radius: 4px 4px 0 0;
margin-bottom: -1px;
position: relative;
z-index: 2;
}
.alarm-table {
width: 100%;