修改了可视化大屏的动画BUg

This commit is contained in:
wzclm 2025-03-13 17:46:41 +08:00
parent 0d5d1e6072
commit a8b6fb4b8b
15 changed files with 605 additions and 386 deletions

2
.env
View File

@ -22,4 +22,4 @@ VITE_BUILD_BROTLI=false
VITE_DROP_CONSOLE=true VITE_DROP_CONSOLE=true
# 后端 API 的基础 URL # 后端 API 的基础 URL
VITE_API_BASE_URL=http://localhost:3000 VITE_API_BASE_URL=http://127.0.0.1:3000

View File

@ -2,7 +2,7 @@
VITE_NODE_ENV=development VITE_NODE_ENV=development
# API 基础路径 # API 基础路径
VITE_API_BASE_URL=http://localhost:3000 VITE_API_BASE_URL=http://127.0.0.1:3000
# 项目基础路径 # 项目基础路径
VITE_BASE_URL=/ VITE_BASE_URL=/

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View File

@ -2,7 +2,7 @@ import flvjs from 'flv.js'
// 视频流服务器配置 // 视频流服务器配置
const VIDEO_SERVER = { const VIDEO_SERVER = {
url: 'http://localhost:8799/live', url: 'http://127.0.0.1:8799/live',
type: 'flv' type: 'flv'
} }

View File

@ -253,19 +253,19 @@ const handleKeyPress = (event) => {
let baseValue; let baseValue;
switch (key) { switch (key) {
case "h": case "h":
// 1185±15 // 1185±3
baseValue = 1185; baseValue = 1185;
randomValue = baseValue + (Math.random() * 30 - 15); randomValue = baseValue + (Math.random() * 6 - 3);
break; break;
case "c": case "c":
// 224±8 // 224±2
baseValue = 224; baseValue = 224;
randomValue = baseValue + (Math.random() * 16 - 8); randomValue = baseValue + (Math.random() * 4 - 2);
break; break;
case "f": case "f":
// 470±10 // 470±2
baseValue = 470; baseValue = 470;
randomValue = baseValue + (Math.random() * 20 - 10); randomValue = baseValue + (Math.random() * 4 - 2);
break; break;
} }

View File

@ -85,7 +85,7 @@ const imageUrl = computed(() => {
if (url.startsWith('data:')) return url if (url.startsWith('data:')) return url
if (url.startsWith('http')) return url if (url.startsWith('http')) return url
// 使URL // 使URL
return `http://localhost:3000${url}` return `http://127.0.0.1:3000${url}`
}) })
// //
@ -519,7 +519,7 @@ onMounted(() => {
<template v-else> <template v-else>
<el-upload <el-upload
class="avatar-uploader" class="avatar-uploader"
action="http://localhost:3000/api/admin/projects/upload" action="http://127.0.0.1:3000/api/admin/projects/upload"
:headers="uploadHeaders" :headers="uploadHeaders"
:show-file-list="false" :show-file-list="false"
accept="image/*" accept="image/*"

View File

@ -92,7 +92,7 @@ const getList = async () => {
cover_image: item.cover_image cover_image: item.cover_image
? (item.cover_image.startsWith('http') ? (item.cover_image.startsWith('http')
? item.cover_image ? item.cover_image
: `http://localhost:3000${item.cover_image}`) : `http://127.0.0.1:3000${item.cover_image}`)
: '', : '',
created_at: formatDateTime(item.created_at), created_at: formatDateTime(item.created_at),
updated_at: formatDateTime(item.updated_at) updated_at: formatDateTime(item.updated_at)
@ -222,7 +222,7 @@ const handleEdit = (row) => {
category: row.category, category: row.category,
content: row.content, content: row.content,
cover_image: row.cover_image && !row.cover_image.startsWith('http') cover_image: row.cover_image && !row.cover_image.startsWith('http')
? `http://localhost:3000${row.cover_image}` ? `http://127.0.0.1:3000${row.cover_image}`
: row.cover_image, : row.cover_image,
tags: row.tags || [], tags: row.tags || [],
status: row.status, status: row.status,
@ -243,7 +243,7 @@ const handleSubmit = async () => {
title: form.value.title.trim(), title: form.value.title.trim(),
category: form.value.category, category: form.value.category,
content: form.value.content.trim(), content: form.value.content.trim(),
cover_image: form.value.cover_image.replace('http://localhost:3000', ''), cover_image: form.value.cover_image.replace('http://127.0.0.1:3000', ''),
tags: form.value.tags, tags: form.value.tags,
status: Number(form.value.status) status: Number(form.value.status)
} }
@ -339,7 +339,7 @@ const handleUploadSuccess = (response) => {
// 使URL // 使URL
form.value.cover_image = imageUrl.startsWith('http') form.value.cover_image = imageUrl.startsWith('http')
? imageUrl ? imageUrl
: `http://localhost:3000${imageUrl}` : `http://127.0.0.1:3000${imageUrl}`
ElMessage.success('图片上传成功') ElMessage.success('图片上传成功')
} else { } else {
form.value.cover_image = '' form.value.cover_image = ''
@ -547,7 +547,7 @@ onMounted(() => {
<template v-else> <template v-else>
<el-upload <el-upload
class="image-upload" class="image-upload"
action="http://localhost:3000/api/education/knowledge/upload" action="http://127.0.0.1:3000/api/education/knowledge/upload"
:headers="uploadHeaders" :headers="uploadHeaders"
name="cover_image" name="cover_image"
:show-file-list="false" :show-file-list="false"

View File

@ -204,7 +204,7 @@ const getFileNameFromUrl = (url) => {
} }
// http // http
if (cleanUrl.includes('localhost:3000')) { if (cleanUrl.includes('127.0.0.1:3000')) {
const matches = cleanUrl.match(/([^/]+)$/) const matches = cleanUrl.match(/([^/]+)$/)
return matches ? matches[1] : cleanUrl return matches ? matches[1] : cleanUrl
} }
@ -223,14 +223,14 @@ const processImageUrl = (url) => {
if (!url) return '' if (!url) return ''
// URL // URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/images\/[^/]+$/)) { if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/images\/[^/]+$/)) {
return url return url
} }
// //
const fileName = getFileNameFromUrl(url) const fileName = getFileNameFromUrl(url)
// URL // URL
return `http://localhost:3000/uploads/courses/images/${fileName}` return `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
} }
// URL // URL
@ -238,14 +238,14 @@ const processVideoUrl = (url) => {
if (!url) return '' if (!url) return ''
// URL // URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/videos\/[^/]+$/)) { if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/videos\/[^/]+$/)) {
return url return url
} }
// //
const fileName = getFileNameFromUrl(url) const fileName = getFileNameFromUrl(url)
// URL // URL
return `http://localhost:3000/uploads/courses/videos/${fileName}` return `http://127.0.0.1:3000/uploads/courses/videos/${fileName}`
} }
// //
@ -260,7 +260,7 @@ const handleImageSuccess = (response) => {
console.log('上传返回的图片信息:', response.data) console.log('上传返回的图片信息:', response.data)
// //
const fileName = response.data.url.split('/').pop() const fileName = response.data.url.split('/').pop()
const imageUrl = `http://localhost:3000/uploads/courses/images/${fileName}` const imageUrl = `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
// //
form.value = { form.value = {
@ -336,7 +336,7 @@ const handleEdit = (row) => {
} }
// http // http
if (cleanUrl.includes('localhost:3000')) { if (cleanUrl.includes('127.0.0.1:3000')) {
const matches = cleanUrl.match(/([^/]+)$/) const matches = cleanUrl.match(/([^/]+)$/)
return matches ? matches[1] : cleanUrl return matches ? matches[1] : cleanUrl
} }
@ -355,14 +355,14 @@ const handleEdit = (row) => {
if (!url) return '' if (!url) return ''
// URL // URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/images\/[^/]+$/)) { if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/images\/[^/]+$/)) {
return url return url
} }
// //
const fileName = getFileNameFromUrl(url) const fileName = getFileNameFromUrl(url)
// URL // URL
return `http://localhost:3000/uploads/courses/images/${fileName}` return `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
} }
// URL // URL
@ -370,14 +370,14 @@ const handleEdit = (row) => {
if (!url) return '' if (!url) return ''
// URL // URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/videos\/[^/]+$/)) { if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/videos\/[^/]+$/)) {
return url return url
} }
// //
const fileName = getFileNameFromUrl(url) const fileName = getFileNameFromUrl(url)
// URL // URL
return `http://localhost:3000/uploads/courses/videos/${fileName}` return `http://127.0.0.1:3000/uploads/courses/videos/${fileName}`
} }
// //
@ -389,9 +389,9 @@ const handleEdit = (row) => {
title: row.title, title: row.title,
category: row.category, category: row.category,
cover_image: fileName, // cover_image: fileName, //
cover_image_url: `http://localhost:3000/uploads/courses/images/${fileName}`, // URL cover_image_url: `http://127.0.0.1:3000/uploads/courses/images/${fileName}`, // URL
video: videoFileName, // video: videoFileName, //
video_url: `http://localhost:3000/uploads/courses/videos/${videoFileName}`, // URL video_url: `http://127.0.0.1:3000/uploads/courses/videos/${videoFileName}`, // URL
video_duration: row.video_duration || 0, video_duration: row.video_duration || 0,
video_size: row.video_size || 0, video_size: row.video_size || 0,
description: row.description || '', description: row.description || '',
@ -535,7 +535,7 @@ const handleImageError = (e) => {
// //
const fileName = getFileNameFromUrl(form.value.cover_image) const fileName = getFileNameFromUrl(form.value.cover_image)
// 使URL // 使URL
img.src = `http://localhost:3000/uploads/courses/images/${fileName}` img.src = `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
// //
img.dataset.retryCount = String(Number(img.dataset.retryCount) + 1) img.dataset.retryCount = String(Number(img.dataset.retryCount) + 1)
} }
@ -546,7 +546,7 @@ const handleVideoSuccess = (response) => {
console.log('上传返回的视频信息:', response.data) console.log('上传返回的视频信息:', response.data)
// //
const fileName = response.data.url.split('/').pop() const fileName = response.data.url.split('/').pop()
const videoUrl = `http://localhost:3000/uploads/courses/videos/${fileName}` const videoUrl = `http://127.0.0.1:3000/uploads/courses/videos/${fileName}`
// //
form.value = { form.value = {
@ -781,7 +781,7 @@ onMounted(() => {
<template v-else> <template v-else>
<el-upload <el-upload
class="image-upload" class="image-upload"
action="http://localhost:3000/api/education/knowledge/upload" action="http://127.0.0.1:3000/api/education/knowledge/upload"
:headers="uploadHeaders" :headers="uploadHeaders"
name="cover_image" name="cover_image"
:show-file-list="false" :show-file-list="false"
@ -823,7 +823,7 @@ onMounted(() => {
<template v-else> <template v-else>
<el-upload <el-upload
class="video-upload" class="video-upload"
action="http://localhost:3000/api/education/courses/upload/video" action="http://127.0.0.1:3000/api/education/courses/upload/video"
:headers="uploadHeaders" :headers="uploadHeaders"
name="video" name="video"
:show-file-list="false" :show-file-list="false"

View File

@ -44,6 +44,7 @@ const categoryChartRef = ref(null);
let categoryChart = null; let categoryChart = null;
const protectionChartRef = ref(null); const protectionChartRef = ref(null);
let protectionChart = null; let protectionChart = null;
const trendChartRef = ref(null);
// //
const statsCards = ref([ const statsCards = ref([
@ -252,7 +253,6 @@ const initProtectionChart = () => {
protectionChart = echarts.init(protectionChartRef.value); protectionChart = echarts.init(protectionChartRef.value);
const option = { const option = {
title: { title: {
text: "保护等级统计",
textStyle: { textStyle: {
fontSize: 16, fontSize: 16,
fontWeight: 500, fontWeight: 500,
@ -361,7 +361,6 @@ const initTrendChart = () => {
const myChart = echarts.init(chartDom); const myChart = echarts.init(chartDom);
const option = { const option = {
title: { title: {
text: "近7天监测数据趋势",
textStyle: { textStyle: {
fontSize: 16, fontSize: 16,
fontWeight: 500, fontWeight: 500,
@ -412,6 +411,14 @@ const initTrendChart = () => {
}, },
yAxis: { yAxis: {
type: "value", type: "value",
max: 50,
interval: 10,
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: "#EBEEF5", color: "#EBEEF5",
@ -450,7 +457,7 @@ const initTrendChart = () => {
shadowBlur: 10, shadowBlur: 10,
}, },
}, },
data: [120, 132, 101, 134, 90, 230, 210], data: [10, 12, 15, 13, 18, 15, 20],
}, },
{ {
name: "监测数据", name: "监测数据",
@ -479,14 +486,12 @@ const initTrendChart = () => {
shadowBlur: 10, shadowBlur: 10,
}, },
}, },
data: [220, 182, 191, 234, 290, 330, 310], data: [15, 18, 12, 20, 15, 18, 16],
}, },
], ],
}; };
myChart.setOption(option); myChart.setOption(option);
//
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
myChart.resize(); myChart.resize();
}); });
@ -631,10 +636,10 @@ const fetchStatisticsData = async () => {
onMounted(() => { onMounted(() => {
initData(); initData();
startAutoRefresh(); startAutoRefresh();
initTrendChart();
initDistributionChart();
initCategoryChart(); initCategoryChart();
initProtectionChart(); initProtectionChart();
initTrendChart();
initDistributionChart();
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
categoryChart?.resize(); categoryChart?.resize();
protectionChart?.resize(); protectionChart?.resize();
@ -818,67 +823,6 @@ onUnmounted(() => {
} }
} }
.chart-card {
border: none;
border-radius: 8px;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
}
.alert-card {
border: none;
border-radius: 8px;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
:deep(.el-timeline-item__node) {
background-color: transparent;
border: 2px solid v.$primary-color;
}
:deep(.el-timeline-item__tail) {
border-left: 2px solid #e4e7ed;
}
}
.mb-20 {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
color: v.$text-primary;
position: relative;
padding-left: 12px;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background: v.$primary-color;
border-radius: 2px;
}
}
}
.charts-container { .charts-container {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -887,10 +831,13 @@ onUnmounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
.chart-item { .chart-item {
border-radius: 8px;
padding: 20px;
.chart-title { .chart-title {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: v.$text-primary; color: #303133;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -49,6 +49,12 @@ const isBuffering = ref(false) // 添加缓冲状态标志
let chart = null let chart = null
let ws = null let ws = null
// WebSocket
const wsConnected = ref(false)
let wsRetryCount = 0
const MAX_RETRY_COUNT = 3
let wsRetryTimer = null
// //
const getChartData = async () => { const getChartData = async () => {
try { try {
@ -198,29 +204,83 @@ const showDeviceStatusNotification = (status) => {
}) })
} }
// WebSocket // WebSocket
const initWebSocket = () => { const initWebSocket = () => {
ws = new WebSocket('ws://192.168.1.158:6894') 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 = () => { ws.onopen = () => {
console.log('WebSocket连接成功') console.log('WebSocket连接成功')
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) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'device_status') { if (data.type === 'device_status') {
// drone.value
if (!drone.value || (drone.value && drone.value.code === data.device_code)) {
//
if (drone.value) {
const newStatus = data.status === 1 ? 'online' : 'offline' const newStatus = data.status === 1 ? 'online' : 'offline'
if (drone.value.status !== newStatus) { 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 drone.value.status = newStatus
} }
//
showDeviceStatusNotification(newStatus)
//
nextTick(async () => {
if (newStatus === 'online') {
if (chart) {
chart.dispose()
chart = null
} }
// await initVideoPlayer()
getDroneInfo() } else {
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
getChartData()
}
})
} }
} }
} catch (error) { } catch (error) {
@ -230,17 +290,61 @@ const initWebSocket = () => {
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket错误:', 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 = () => { ws.onclose = () => {
// console.log('WebSocket连接关闭')
setTimeout(initWebSocket, 5000) 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 () => { const getDroneInfo = async () => {
if (loading.value) return
loading.value = true loading.value = true
try { try {
const res = await getDeviceList({ const res = await getDeviceList({
page: 1, page: 1,
@ -249,18 +353,14 @@ const getDroneInfo = async () => {
}) })
if (res.code === 200) { if (res.code === 200) {
const targetDrone = res.data.list.find(item => { const targetDrone = res.data.list.find(item =>
return item.device_code === 'BC13292E5A49914F4D62B9F356E39F56' item.device_code === 'BC13292E5A49914F4D62B9F356E39F56'
}) )
if (targetDrone) { if (targetDrone) {
// //
// 1. WebSocket
const wsConnected = ws?.readyState === WebSocket.OPEN
// 2. - 使 WebSocket
const deviceStatus = targetDrone.status?.code || targetDrone.status const deviceStatus = targetDrone.status?.code || targetDrone.status
const deviceOnline = wsConnected && (deviceStatus === 1 || (drone.value?.status === 'online')) const deviceOnline = deviceStatus === 1
// 3.
const newStatus = deviceOnline ? 'online' : 'offline' const newStatus = deviceOnline ? 'online' : 'offline'
// //
@ -269,6 +369,8 @@ const getDroneInfo = async () => {
showDeviceStatusNotification(newStatus) showDeviceStatusNotification(newStatus)
} }
//
if (!drone.value || oldStatus !== newStatus) {
drone.value = { drone.value = {
id: targetDrone.id, id: targetDrone.id,
name: targetDrone.device_name, name: targetDrone.device_name,
@ -277,19 +379,19 @@ const getDroneInfo = async () => {
battery: targetDrone.battery_level || 0, battery: targetDrone.battery_level || 0,
signal: targetDrone.signal_strength || 0 signal: targetDrone.signal_strength || 0
} }
}
} else { } else {
if (drone.value !== null) {
drone.value = null drone.value = null
getChartData() getChartData()
} }
} else { }
console.error('API响应错误:', res.message)
ElMessage.error(res.message || '获取无人机列表失败')
getChartData()
} }
} catch (error) { } catch (error) {
console.error('获取无人机列表错误:', error) console.error('获取设备信息失败:', error)
ElMessage.error('获取无人机列表失败') if (!drone.value) {
getChartData() getChartData()
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -299,21 +401,42 @@ const getDroneInfo = async () => {
const initVideoPlayer = async () => { const initVideoPlayer = async () => {
videoLoading.value = true // loading videoLoading.value = true // loading
isPlaying.value = false // isPlaying.value = false //
isBuffering.value = false //
try {
// //
if (flvPlayer.value) { if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value) destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null flvPlayer.value = null
} }
// DOM
await nextTick()
// //
if (videoRef.value && drone.value?.status === 'online') { if (videoRef.value && drone.value?.status === 'online') {
flvPlayer.value = createFlvPlayer(videoRef.value, drone.value.code) //
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) { if (flvPlayer.value) {
// flv.js
flvPlayer.value.on('loading', () => { flvPlayer.value.on('loading', () => {
if (!isPlaying.value) { if (!isPlaying.value) {
videoLoading.value = true videoLoading.value = true
isBuffering.value = true
} }
}) })
@ -322,39 +445,68 @@ const initVideoPlayer = async () => {
ElMessage.error('视频加载失败,请检查网络连接') ElMessage.error('视频加载失败,请检查网络连接')
videoLoading.value = false videoLoading.value = false
isPlaying.value = false isPlaying.value = false
isBuffering.value = false
}) })
// //
videoRef.value.addEventListener('waiting', () => { 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 isBuffering.value = true
// loading
if (!isPlaying.value) { if (!isPlaying.value) {
videoLoading.value = true videoLoading.value = true
} }
}) })
videoRef.value.addEventListener('playing', () => { addVideoListener('playing', () => {
//
videoLoading.value = false videoLoading.value = false
isPlaying.value = true isPlaying.value = true
isBuffering.value = false isBuffering.value = false
}) })
videoRef.value.addEventListener('canplay', () => { addVideoListener('canplay', () => {
//
isBuffering.value = false isBuffering.value = false
if (!isPlaying.value) { if (!isPlaying.value) {
videoLoading.value = false videoLoading.value = false
} }
}) })
videoRef.value.addEventListener('error', () => { addVideoListener('error', (e) => {
console.error('视频播放错误') console.error('视频元素错误:', e)
videoLoading.value = false videoLoading.value = false
isPlaying.value = false isPlaying.value = false
isBuffering.value = false
ElMessage.error('视频加载失败,请检查网络连接') 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 { } else {
console.error('播放器创建失败') console.error('播放器创建失败')
ElMessage.error('视频播放器初始化失败') ElMessage.error('视频播放器初始化失败')
@ -362,17 +514,29 @@ const initVideoPlayer = async () => {
isPlaying.value = false isPlaying.value = false
} }
} }
} catch (error) {
console.error('视频播放器初始化失败:', error)
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
isPlaying.value = false
isBuffering.value = false
}
} }
// //
let refreshTimer = null let refreshTimer = null
const startRefreshTimer = () => { const startRefreshTimer = () => {
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
// WebSocketHTTP
if (ws?.readyState !== WebSocket.OPEN) {
getDroneInfo() getDroneInfo()
}
// 线
if (drone.value?.status !== 'online') { if (drone.value?.status !== 'online') {
getChartData() getChartData()
} }
}, 30000) // 30 }, 60000) // 60
} }
// //
@ -430,14 +594,25 @@ onUnmounted(() => {
document.removeEventListener('click', handleUserInteraction) document.removeEventListener('click', handleUserInteraction)
document.removeEventListener('keydown', handleUserInteraction) document.removeEventListener('keydown', handleUserInteraction)
// WebSocket // WebSocket
if (ws) { if (ws) {
ws._closeInitiated = true
ws.close() ws.close()
ws = null ws = null
} }
//
if (wsRetryTimer) {
clearTimeout(wsRetryTimer)
}
if (pollingTimer) {
clearInterval(pollingTimer)
}
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer) clearInterval(refreshTimer)
} }
//
if (flvPlayer.value) { if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value) destroyFlvPlayer(flvPlayer.value)
} }
@ -445,6 +620,8 @@ onUnmounted(() => {
chart.dispose() chart.dispose()
chart = null chart = null
} }
wsConnected.value = false
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
}) })
</script> </script>

View File

@ -18,6 +18,12 @@ let resizeObserver = null
// //
const fetchLatestData = async () => { const fetchLatestData = async () => {
try { try {
//
const chartDom = chartRef.value
if (chartDom) {
chartDom.classList.add('loading')
}
const res = await getLatestData() const res = await getLatestData()
if (res.success && res.data) { if (res.success && res.data) {
monitoringData.value = res.data monitoringData.value = res.data
@ -25,6 +31,12 @@ const fetchLatestData = async () => {
} }
} catch (error) { } catch (error) {
console.error('获取最新数据失败:', error) console.error('获取最新数据失败:', error)
} finally {
//
const chartDom = chartRef.value
if (chartDom) {
chartDom.classList.remove('loading')
}
} }
} }
@ -92,73 +104,120 @@ const initChart = async () => {
const option = { const option = {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'item', trigger: 'axis',
formatter: '{b}: {c}' axisPointer: {
type: 'shadow'
}, },
radar: { backgroundColor: 'rgba(0, 24, 65, 0.8)',
center: ['50%', '50%'], borderColor: 'rgba(63, 167, 221, 0.2)',
radius: '65%', textStyle: {
indicator: [], color: '#fff'
shape: 'circle', }
splitNumber: 8, },
axisName: { grid: {
top: '15%',
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: [],
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
interval: 0,
rotate: 45
},
axisTick: {
show: false
}
},
yAxis: [
{
type: 'value',
name: '监测值',
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.7)', color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12 fontSize: 12
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: 'rgba(255, 255, 255, 0.2)', color: 'rgba(255, 255, 255, 0.1)',
width: 2, type: 'dashed'
type: 'dashed',
dashOffset: 0
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
} }
}, },
axisLine: { axisLine: {
show: true, show: false
lineStyle: { },
color: 'rgba(255, 255, 255, 0.3)' axisTick: {
} show: false
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12
} }
}, },
series: [] {
type: 'value',
name: '变化率',
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12
},
splitLine: {
show: false
},
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
formatter: '{value}%'
}
}
],
series: [
{
name: '监测值',
type: 'bar',
barWidth: 20,
data: [],
z: 10
},
{
name: '变化率',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
color: '#67C23A'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
{ offset: 1, color: 'rgba(103, 194, 58, 0.1)' }
])
},
data: [],
z: 11
}
]
} }
chart.setOption(option) chart.setOption(option)
//
let angle = 0
let dashOffset = 0
const animate = () => {
angle = (angle + 0.5) % 360
dashOffset = (dashOffset + 1) % 20
chart.setOption({
radar: {
startAngle: angle,
splitLine: {
lineStyle: {
dashOffset: -dashOffset
}
}
}
})
requestAnimationFrame(animate)
}
animate()
//
setTimeout(() => {
if (chart) {
chart.resize()
}
}, 200)
return chart return chart
} }
@ -172,111 +231,113 @@ const updateChart = () => {
const points = processedData.value const points = processedData.value
if (points.length === 0) return if (points.length === 0) return
// //
const indicators = new Set() const allIndicators = new Set()
const maxValues = {} const indicatorValues = {}
const changeRates = {}
points.forEach(point => { points.forEach(point => {
point.indicators.forEach(indicator => { point.indicators.forEach(indicator => {
indicators.add(indicator.name) allIndicators.add(indicator.name)
if (!maxValues[indicator.name] || maxValues[indicator.name] < indicator.value) { if (!indicatorValues[indicator.name]) {
maxValues[indicator.name] = indicator.value indicatorValues[indicator.name] = []
} }
indicatorValues[indicator.name].push(indicator.value)
}) })
}) })
// //
const radarIndicators = Array.from(indicators).map(name => ({ allIndicators.forEach(name => {
name: name, const values = indicatorValues[name]
max: Math.ceil(maxValues[name] * 1.2) // 20% if (values.length >= 2) {
})) const current = values[values.length - 1]
const previous = values[values.length - 2]
// changeRates[name] = previous ? ((current - previous) / previous * 100) : 0
const series = [{ } else {
type: 'radar', changeRates[name] = 0
animation: true,
animationDuration: 2000,
animationEasing: 'quadraticInOut',
data: points.map(point => {
const pointType = point.type
const colors = {
water: ['rgba(64, 158, 255, 1)', 'rgba(54, 206, 158, 0.1)'],
air: ['rgba(230, 162, 60, 1)', 'rgba(245, 108, 108, 0.1)'],
soil: ['rgba(103, 194, 58, 1)', 'rgba(149, 212, 117, 0.1)'],
default: ['rgba(64, 158, 255, 1)', 'rgba(54, 206, 158, 0.1)']
} }
const colorSet = colors[pointType] || colors.default })
return { //
name: point.point, const xAxisData = Array.from(allIndicators)
value: radarIndicators.map(indicator => { const barData = xAxisData.map(name => {
const ind = point.indicators.find(i => i.name === indicator.name) const values = indicatorValues[name]
return ind ? ind.value : 0 return values[values.length - 1] || 0
}), })
symbol: 'circle', const lineData = xAxisData.map(name => changeRates[name] || 0)
symbolSize: 6,
itemStyle: { //
color: colorSet[0], chart.setOption({
borderColor: '#fff', xAxis: {
borderWidth: 2, data: xAxisData
shadowColor: colorSet[0],
shadowBlur: 10
}, },
series: [
{
name: '监测值',
type: 'bar',
barWidth: 20,
data: barData.map(value => ({
value: 0,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(63, 167, 221, 0.9)' },
{ offset: 1, color: 'rgba(63, 167, 221, 0.2)' }
]),
borderRadius: [4, 4, 0, 0]
}
}))
},
{
name: '变化率',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: false,
lineStyle: { lineStyle: {
color: colorSet[0], width: 3,
width: 2, color: '#67C23A'
type: [5, 10],
shadowColor: colorSet[0],
shadowBlur: 5
}, },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colorSet[0] }, { offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
{ offset: 1, color: colorSet[1] } { offset: 1, color: 'rgba(103, 194, 58, 0.1)' }
]), ])
opacity: 0.5
}, },
emphasis: { data: lineData.map(() => 0)
scale: true, }
]
})
//
setTimeout(() => {
chart.setOption({
series: [
{
name: '监测值',
data: barData.map(value => ({
value,
itemStyle: { itemStyle: {
shadowBlur: 20 color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(63, 167, 221, 0.9)' },
{ offset: 1, color: 'rgba(63, 167, 221, 0.2)' }
]),
borderRadius: [4, 4, 0, 0]
}
})),
animationDelay: (idx) => idx * 100,
animationDuration: 1000,
animationEasing: 'cubicInOut'
}, },
areaStyle: { {
opacity: 0.8 name: '变化率',
} data: lineData,
} animationDelay: (idx) => idx * 100 + 500,
animationDuration: 1000,
animationEasing: 'cubicInOut'
} }
]
}) })
}] }, 100)
chart.setOption({
radar: {
indicator: radarIndicators
},
series: series
})
//
let breatheEffect = 0
const animate = () => {
breatheEffect = (breatheEffect + 1) % 100
const opacity = 0.3 + Math.sin(breatheEffect * Math.PI / 50) * 0.2
series[0].data.forEach((item, index) => {
chart.setOption({
series: [{
data: series[0].data.map((dataItem, i) => ({
...dataItem,
areaStyle: {
...dataItem.areaStyle,
opacity: i === index ? opacity : 0.3
}
}))
}]
})
})
requestAnimationFrame(animate)
}
animate()
} }
// //
@ -417,6 +478,19 @@ onUnmounted(() => {
.chart { .chart {
height: 100%; height: 100%;
width: 100%; width: 100%;
position: relative;
&.loading::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 24, 65, 0.3);
z-index: 10;
animation: pulse 1s infinite;
}
} }
} }
} }
@ -427,4 +501,16 @@ onUnmounted(() => {
50% { opacity: 1; } 50% { opacity: 1; }
100% { opacity: 0.2; } 100% { opacity: 0.2; }
} }
@keyframes pulse {
0% {
opacity: 0.3;
}
50% {
opacity: 0.1;
}
100% {
opacity: 0.3;
}
}
</style> </style>

View File

@ -28,9 +28,9 @@ const initChart = async () => {
formatter: function(params) { formatter: function(params) {
const item = params[0] const item = params[0]
return `${item.name}<br/> return `${item.name}<br/>
预警次数${item.value}<br/> 预警次数${item.value}<br/>
预警天数${item.data.days}<br/> 预警天数${item.data.days}<br/>
日均预警${item.data.avg.toFixed(2)}/` 日均预警${item.data.avg.toFixed(2)}/`
} }
}, },
grid: { grid: {

View File

@ -119,36 +119,45 @@ onUnmounted(() => {
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
font-family: 'DIN Alternate', 'Arial', sans-serif; font-family: 'DIN Alternate', 'Arial', sans-serif;
font-size: 24px; font-size: 28px;
font-weight: 500; font-weight: 600;
color: #3fa7dd; color: #3fa7dd;
text-shadow: 0 0 10px rgba(63, 167, 221, 0.5); text-shadow: 0 0 15px rgba(63, 167, 221, 0.6);
padding: 8px 15px; padding: 12px 24px;
border-radius: 4px; border-radius: 8px;
background: rgba(0, 24, 65, 0.3); background: rgba(0, 24, 65, 0.5);
border: 1px solid rgba(63, 167, 221, 0.2); border: 1px solid rgba(63, 167, 221, 0.3);
letter-spacing: 1px; letter-spacing: 2px;
backdrop-filter: blur(4px); backdrop-filter: blur(8px);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
box-shadow: 0 0 20px rgba(63, 167, 221, 0.2);
transition: all 0.3s ease;
&:hover {
background: rgba(0, 24, 65, 0.7);
border-color: rgba(63, 167, 221, 0.5);
box-shadow: 0 0 30px rgba(63, 167, 221, 0.3);
}
&::before { &::before {
content: ''; content: '';
width: 8px; width: 10px;
height: 8px; height: 10px;
background: #3fa7dd; background: #3fa7dd;
border-radius: 50%; border-radius: 50%;
animation: pulse 1s infinite; box-shadow: 0 0 15px rgba(63, 167, 221, 0.8);
animation: pulse 1.5s infinite;
} }
} }
@keyframes pulse { @keyframes pulse {
0% { 0% {
box-shadow: 0 0 0 0 rgba(63, 167, 221, 0.4); box-shadow: 0 0 0 0 rgba(63, 167, 221, 0.6);
} }
70% { 70% {
box-shadow: 0 0 0 6px rgba(63, 167, 221, 0); box-shadow: 0 0 0 10px rgba(63, 167, 221, 0);
} }
100% { 100% {
box-shadow: 0 0 0 0 rgba(63, 167, 221, 0); box-shadow: 0 0 0 0 rgba(63, 167, 221, 0);

View File

@ -241,7 +241,7 @@ const handleUploadSuccess = (response) => {
ElMessage.error('上传成功但未获取到图片地址') ElMessage.error('上传成功但未获取到图片地址')
return return
} }
form.value.image_url = imageUrl.startsWith('http') ? imageUrl : `http://localhost:3000${imageUrl}` form.value.image_url = imageUrl.startsWith('http') ? imageUrl : `http://127.0.0.1:3000${imageUrl}`
ElMessage.success('图片上传成功') ElMessage.success('图片上传成功')
} else { } else {
ElMessage.error(response.message || '图片上传失败') ElMessage.error(response.message || '图片上传失败')
@ -408,7 +408,7 @@ onMounted(() => {
<template v-else> <template v-else>
<el-upload <el-upload
class="carousel-uploader" class="carousel-uploader"
action="http://localhost:3000/api/admin/carousels/upload" action="http://127.0.0.1:3000/api/admin/carousels/upload"
:headers="uploadHeaders" :headers="uploadHeaders"
:show-file-list="false" :show-file-list="false"
:on-success="handleUploadSuccess" :on-success="handleUploadSuccess"