942 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { getDeviceList } from '@/api/device'
import { createFlvPlayer, destroyFlvPlayer } from '@/utils/videoPlayer'
import * as echarts from 'echarts'
import { generateChartData } from '@/api/chart'
import { ElNotification, ElMessage } from 'element-plus'
import {
Monitor,
Connection,
Loading
} from '@element-plus/icons-vue'
import soundEffect from '@/utils/SoundEffect'
// 添加用户交互标记
let hasUserInteracted = false
// 监听用户交互
const handleUserInteraction = () => {
hasUserInteracted = true
// 移除事件监听器
document.removeEventListener('click', handleUserInteraction)
document.removeEventListener('keydown', handleUserInteraction)
}
// 无人机数据
const drone = ref(null)
const loading = ref(false)
const videoRef = ref(null)
const flvPlayer = ref(null)
const chartRef = ref(null)
const videoLoading = ref(true) // 添加视频加载状态
const isPlaying = ref(false) // 添加视频播放状态标志
const isBuffering = ref(false) // 添加缓冲状态标志
let chart = null
let ws = null
// 添加 WebSocket 状态管理
const wsConnected = ref(false)
let wsRetryCount = 0
const MAX_RETRY_COUNT = 3
let wsRetryTimer = null
// 存储键名常量
const CHART_DATA_STORAGE_KEY = 'wetland_ph_chart_data'
const CHART_DATA_TIMESTAMP_KEY = 'wetland_ph_chart_timestamp'
const CHART_DATA_EXPIRE_TIME = 24 * 60 * 60 * 1000 // 24小时过期
// 保存图表数据到本地存储
const saveChartDataToStorage = (data) => {
try {
localStorage.setItem(CHART_DATA_STORAGE_KEY, JSON.stringify(data))
localStorage.setItem(CHART_DATA_TIMESTAMP_KEY, Date.now().toString())
} catch (error) {
console.error('保存图表数据到本地存储失败:', error)
}
}
// 从本地存储获取图表数据
const getChartDataFromStorage = () => {
try {
const timestamp = localStorage.getItem(CHART_DATA_TIMESTAMP_KEY)
if (!timestamp) return null
// 检查数据是否过期
if (Date.now() - parseInt(timestamp) > CHART_DATA_EXPIRE_TIME) {
localStorage.removeItem(CHART_DATA_STORAGE_KEY)
localStorage.removeItem(CHART_DATA_TIMESTAMP_KEY)
return null
}
const data = localStorage.getItem(CHART_DATA_STORAGE_KEY)
return data ? JSON.parse(data) : null
} catch (error) {
console.error('从本地存储获取图表数据失败:', error)
return null
}
}
// 获取图表数据
const getChartData = async () => {
try {
const res = await generateChartData({
message: "ph柱状图"
})
if (res.success) {
console.log('完整的响应数据:', res);
console.log('图表配置数据:', res.data?.echart_options);
if (!res.data?.echart_options) {
console.error('图表配置数据结构不完整');
// 尝试使用本地存储的数据
const storageData = getChartDataFromStorage()
if (storageData) {
console.log('使用本地存储的图表数据');
initChart(storageData)
} else {
initChart(getDefaultChartData().echart_options)
}
return;
}
// 保存新数据到本地存储
saveChartDataToStorage(res.data.echart_options)
initChart(res.data.echart_options)
} else {
console.error('获取图表数据失败:', res.message)
// 尝试使用本地存储的数据
const storageData = getChartDataFromStorage()
if (storageData) {
console.log('使用本地存储的图表数据');
initChart(storageData)
} else {
initChart(getDefaultChartData().echart_options)
}
}
} catch (error) {
console.error('获取图表数据失败:', error)
// 尝试使用本地存储的数据
const storageData = getChartDataFromStorage()
if (storageData) {
console.log('使用本地存储的图表数据');
initChart(storageData)
} else {
initChart(getDefaultChartData().echart_options)
}
}
}
// 初始化图表
const initChart = (options) => {
if (!chartRef.value) {
console.error('图表DOM引用未找到');
return;
}
try {
if (chart) {
chart.dispose()
}
chart = echarts.init(chartRef.value)
// 检查必要的数据结构
if (!options.xAxis?.data && !options.series?.[0]?.data) {
console.error('数据结构不正确,使用默认配置');
options = getDefaultChartData().echart_options;
}
// 确保 yAxis 配置正确
const yAxisConfig = Array.isArray(options.yAxis) ? options.yAxis[0] : options.yAxis;
const option = {
backgroundColor: 'transparent',
title: {
...(options.title || { text: 'pH值变化趋势' }),
textStyle: {
color: '#fff'
}
},
tooltip: options.tooltip || {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
...(options.legend || {}),
textStyle: {
color: '#fff'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: options.xAxis?.data || [],
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
yAxis: {
type: 'value',
name: 'pH值',
max: 8,
...(yAxisConfig || {}),
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
series: Array.isArray(options.series) ? options.series.map(series => ({
...series,
yAxisIndex: 0, // 确保所有系列都使用同一个 yAxis
areaStyle: series.areaStyle || {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.7)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
])
}
})) : [
{
name: 'pH值',
type: 'line',
smooth: true,
yAxisIndex: 0,
data: [],
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.7)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
])
}
}
]
}
// 设置配置前确保数据结构完整
if (!option.yAxis || !option.xAxis || !option.series) {
throw new Error('图表配置数据结构不完整');
}
chart.setOption(option)
// 确保图表正确渲染
nextTick(() => {
if (chart) {
chart.resize()
}
})
} catch (error) {
console.error('图表初始化失败:', error);
// 尝试使用完全默认的配置重新初始化
try {
if (chart) {
const defaultOption = getDefaultChartData().echart_options;
chart.setOption(defaultOption);
}
} catch (retryError) {
console.error('使用默认配置重试失败:', retryError);
}
}
}
// 修改默认图表数据的结构
const getDefaultChartData = () => {
return {
echart_options: {
title: {
text: "pH值变化趋势"
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
}
},
legend: {
data: ["pH值"]
},
xAxis: {
type: "category",
data: []
},
yAxis: {
type: "value",
name: "pH值",
max: 8
},
series: [
{
name: "pH值",
type: "line",
smooth: true,
yAxisIndex: 0,
areaStyle: {},
data: []
}
]
}
}
}
// 显示设备状态通知
const showDeviceStatusNotification = (status) => {
// 播放对应音效
soundEffect.playStatusSound(status === 'online')
ElNotification({
title: status === 'online' ? '设备已接入' : '设备已接出',
message: status === 'online' ?
`设备 ${drone.value?.name || '无人机'} 已接入,正在获取视频流...` :
`设备 ${drone.value?.name || '无人机'} 已接出,切换为数据图表显示`,
type: status === 'online' ? 'success' : 'warning',
duration: 3000
})
}
// 修改 WebSocket 初始化和重连逻辑
const initWebSocket = () => {
if (wsRetryCount >= MAX_RETRY_COUNT) {
console.warn('WebSocket重试次数超过限制切换到HTTP轮询模式')
startPolling()
return
}
try {
if (ws) {
ws.close()
ws = null
}
// 修改为本地地址
ws = new WebSocket('ws://127.0.0.1:6894')
ws.onopen = () => {
wsConnected.value = true
wsRetryCount = 0 // 重置重试次数
if (wsRetryTimer) {
clearTimeout(wsRetryTimer)
wsRetryTimer = null
}
// WebSocket连接成功后立即查询设备状态
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'query_status',
device_code: 'BC13292E5A49914F4D62B9F356E39F56'
}))
}
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'device_status') {
const newStatus = data.status === 1 ? 'online' : 'offline'
const oldStatus = drone.value?.status
// 只在状态真实发生变化时更新
if (!drone.value || oldStatus !== newStatus) {
// 立即更新设备状态
if (!drone.value) {
drone.value = {
id: data.device_id,
name: data.device_name || '无人机',
code: data.device_code,
status: newStatus,
battery: data.battery_level || 0,
signal: data.signal_strength || 0
}
} else {
drone.value.status = newStatus
}
// 显示状态变化通知
showDeviceStatusNotification(newStatus)
// 根据新状态初始化视频或图表
nextTick(async () => {
if (newStatus === 'online') {
if (chart) {
chart.dispose()
chart = null
}
await initVideoPlayer()
} else {
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
getChartData()
}
})
}
}
} catch (error) {
console.error('WebSocket消息处理错误:', error)
}
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
wsConnected.value = false
wsRetryCount++
// 如果重试次数超过限制切换到HTTP轮询
if (wsRetryCount >= MAX_RETRY_COUNT) {
console.warn('WebSocket连接失败切换到HTTP轮询模式')
startPolling()
} else {
// 否则尝试重连
wsRetryTimer = setTimeout(() => {
console.log(`尝试WebSocket重连 (${wsRetryCount}/${MAX_RETRY_COUNT})...`)
initWebSocket()
}, 5000 * Math.min(wsRetryCount, 3)) // 重试间隔随次数增加但最多15秒
}
}
ws.onclose = () => {
console.log('WebSocket连接关闭')
wsConnected.value = false
// 只有在非主动关闭的情况下才重连
if (!ws._closeInitiated) {
wsRetryTimer = setTimeout(() => {
initWebSocket()
}, 5000)
}
}
} catch (error) {
console.error('WebSocket初始化失败:', error)
startPolling()
}
}
// 添加HTTP轮询模式
let pollingTimer = null
const startPolling = () => {
// 清理已有的定时器
if (pollingTimer) {
clearInterval(pollingTimer)
}
// 立即执行一次
getDroneInfo()
// 设置轮询间隔
pollingTimer = setInterval(() => {
getDroneInfo()
}, 10000) // 10秒轮询一次
}
// 修改获取设备信息的逻辑
const getDroneInfo = async () => {
if (loading.value) return
loading.value = true
try {
const res = await getDeviceList({
page: 1,
page_size: 10,
device_type: '10001'
})
if (res.code === 200) {
const targetDrone = res.data.list.find(item =>
item.device_code === 'BC13292E5A49914F4D62B9F356E39F56'
)
if (targetDrone) {
// 设备状态判断逻辑
const deviceStatus = targetDrone.status?.code || targetDrone.status
const deviceOnline = deviceStatus === 1
const newStatus = deviceOnline ? 'online' : 'offline'
// 检查状态是否发生变化
const oldStatus = drone.value?.status
if (oldStatus && oldStatus !== newStatus) {
showDeviceStatusNotification(newStatus)
}
// 只有在状态发生变化或首次加载时才更新数据
if (!drone.value || oldStatus !== newStatus) {
drone.value = {
id: targetDrone.id,
name: targetDrone.device_name,
code: targetDrone.device_code,
status: newStatus,
battery: targetDrone.battery_level || 0,
signal: targetDrone.signal_strength || 0
}
}
} else {
if (drone.value !== null) {
drone.value = null
getChartData()
}
}
}
} catch (error) {
console.error('获取设备信息失败:', error)
if (!drone.value) {
getChartData()
}
} finally {
loading.value = false
}
}
// 初始化视频播放器
const initVideoPlayer = async () => {
videoLoading.value = true // 开始加载时显示loading
isPlaying.value = false // 重置播放状态
isBuffering.value = false // 重置缓冲状态
try {
// 销毁之前的播放器
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
// 等待 DOM 更新
await nextTick()
// 初始化新的播放器
if (videoRef.value && drone.value?.status === 'online') {
// 重置视频元素
const videoElement = videoRef.value
videoElement.pause()
videoElement.currentTime = 0
videoElement.muted = true
// 移除旧的事件监听器
const oldListeners = videoElement._eventListeners || []
oldListeners.forEach(({ event, handler }) => {
videoElement.removeEventListener(event, handler)
})
videoElement._eventListeners = []
// 创建新的播放器
flvPlayer.value = createFlvPlayer(videoElement, drone.value.code)
if (flvPlayer.value) {
// 添加 flv.js 事件监听
flvPlayer.value.on('loading', () => {
if (!isPlaying.value) {
videoLoading.value = true
isBuffering.value = true
}
})
flvPlayer.value.on('error', (errorType, errorDetail) => {
console.error('视频播放错误:', errorType, errorDetail)
ElMessage.error('视频加载失败,请检查网络连接')
videoLoading.value = false
isPlaying.value = false
isBuffering.value = false
})
// 添加视频元素事件监听
const addVideoListener = (event, handler) => {
videoElement.addEventListener(event, handler)
videoElement._eventListeners.push({ event, handler })
}
addVideoListener('loadstart', () => {
videoLoading.value = true
isBuffering.value = true
})
addVideoListener('waiting', () => {
isBuffering.value = true
if (!isPlaying.value) {
videoLoading.value = true
}
})
addVideoListener('playing', () => {
videoLoading.value = false
isPlaying.value = true
isBuffering.value = false
})
addVideoListener('canplay', () => {
isBuffering.value = false
if (!isPlaying.value) {
videoLoading.value = false
}
})
addVideoListener('error', (e) => {
console.error('视频元素错误:', e)
videoLoading.value = false
isPlaying.value = false
isBuffering.value = false
ElMessage.error('视频加载失败,请检查网络连接')
})
// 尝试播放视频
try {
await videoElement.play()
} catch (error) {
if (error.name === 'AbortError') {
console.log('视频播放被中断,重试中...')
// 短暂延迟后重试播放
setTimeout(async () => {
try {
await videoElement.play()
} catch (retryError) {
console.error('视频重试播放失败:', retryError)
ElMessage.error('视频播放失败,请刷新页面重试')
}
}, 100)
} else {
console.error('视频播放失败:', error)
ElMessage.error('视频播放失败,请检查网络连接')
}
}
} else {
console.error('播放器创建失败')
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
isPlaying.value = false
}
}
} catch (error) {
console.error('视频播放器初始化失败:', error)
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
isPlaying.value = false
isBuffering.value = false
}
}
// 定时刷新设备状态和图表数据
let refreshTimer = null
const startRefreshTimer = () => {
refreshTimer = setInterval(() => {
// 只在WebSocket连接断开时才通过HTTP轮询获取状态
if (ws?.readyState !== WebSocket.OPEN) {
getDroneInfo()
}
// 只在设备离线时才刷新图表数据
if (drone.value?.status !== 'online') {
getChartData()
}
}, 60000) // 改为每60秒刷新一次
}
// 处理窗口大小变化
const handleResize = () => {
chart?.resize()
}
// 监听设备状态变化
watch(() => drone.value?.status, (newStatus, oldStatus) => {
// 设备状态变化
if (newStatus !== oldStatus) {
// 显示状态变化通知
if (oldStatus) { // 只在状态真实变化时显示通知
showDeviceStatusNotification(newStatus)
}
// 根据新状态处理视频或图表
nextTick(async () => {
if (newStatus === 'online') {
// 设备上线,销毁图表并初始化视频播放器
if (chart) {
chart.dispose()
chart = null
}
await initVideoPlayer()
} else {
// 设备离线,销毁视频播放器并初始化图表
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
getChartData()
}
})
}
}, { immediate: true })
onMounted(() => {
// 添加用户交互监听
document.addEventListener('click', handleUserInteraction)
document.addEventListener('keydown', handleUserInteraction)
// 初始化时先尝试使用本地存储的数据
const storageData = getChartDataFromStorage()
if (storageData) {
console.log('使用本地存储的图表数据初始化');
initChart(storageData)
}
// 初始化WebSocket连接
initWebSocket()
// 获取无人机信息
getDroneInfo()
// 启动定时刷新
startRefreshTimer()
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
// 移除用户交互监听
document.removeEventListener('click', handleUserInteraction)
document.removeEventListener('keydown', handleUserInteraction)
// 标记WebSocket为主动关闭
if (ws) {
ws._closeInitiated = true
ws.close()
ws = null
}
// 清理所有定时器
if (wsRetryTimer) {
clearTimeout(wsRetryTimer)
}
if (pollingTimer) {
clearInterval(pollingTimer)
}
if (refreshTimer) {
clearInterval(refreshTimer)
}
// 清理播放器和图表
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
}
if (chart) {
chart.dispose()
chart = null
}
wsConnected.value = false
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="video-monitor">
<div class="monitor-header">
<div class="title">{{ drone?.status === 'online' ? '无人机实时监控' : '历史数据监测' }}</div>
<div class="status" v-if="drone">
<el-tag :type="drone?.status === 'online' ? 'success' : 'danger'" size="small">
{{ drone?.status === 'online' ? '在线' : '离线' }}
</el-tag>
</div>
</div>
<div class="content-container">
<template v-if="drone?.status === 'online'">
<!-- 在线显示视频 -->
<div class="video-wrapper">
<video
ref="videoRef"
class="video-player"
muted
></video>
<div v-if="videoLoading && !isPlaying" class="video-loading">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">视频加载中...</span>
</div>
<div class="video-info">
<div class="info-row">
<span class="device-name">{{ drone.name }}</span>
<div class="device-metrics">
<el-tooltip content="电池电量" placement="top">
<div class="metric">
<el-icon><Monitor /></el-icon>
{{ drone.battery }}%
</div>
</el-tooltip>
<el-tooltip content="信号强度" placement="top">
<div class="metric" :class="{ 'poor-signal': isBuffering }">
<el-icon><Connection /></el-icon>
{{ isBuffering ? '-110dBm' : '-88dBm' }}
</div>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<!-- 离线显示图表 -->
<div ref="chartRef" class="chart-wrapper"></div>
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.video-monitor {
height: 100%;
padding: 20px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
display: flex;
flex-direction: column;
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
font-size: 20px;
font-weight: 500;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
}
.content-container {
flex: 1;
position: relative;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.4);
.video-wrapper {
width: 100%;
height: 100%;
position: relative;
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
}
// 添加 loading 样式
.video-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
z-index: 2;
.loading-icon {
font-size: 32px;
color: #fff;
animation: rotate 1s linear infinite;
}
.loading-text {
color: #fff;
font-size: 14px;
}
}
.video-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
.device-name {
font-size: 16px;
font-weight: 500;
color: #fff;
}
.device-metrics {
display: flex;
gap: 20px;
.metric {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
font-size: 14px;
transition: all 0.3s ease;
&.poor-signal {
color: #F56C6C;
animation: blink 1s infinite;
}
.el-icon {
font-size: 16px;
}
}
}
}
}
}
.chart-wrapper {
width: 100%;
height: 100%;
background: rgba(0, 24, 65, 0.3);
border-radius: 8px;
padding: 20px;
box-sizing: border-box;
border: 1px solid rgba(63, 167, 221, 0.2);
}
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes blink {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
</style>