修改了可视化大屏的动画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
# 后端 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
# API 基础路径
VITE_API_BASE_URL=http://localhost:3000
VITE_API_BASE_URL=http://127.0.0.1:3000
# 项目基础路径
VITE_BASE_URL=/

View File

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

View File

@ -371,13 +371,13 @@ const router = createRouter({
children: [
{
path: 'projects',
name: 'Projects',
name: 'Projects',
component: () => import('@/views/about/projects/index.vue'),
meta: { title: '简介' }
},
{
},
{
path: 'needToKnow',
name: 'NeedToKnow',
name: 'NeedToKnow',
component: () => import('@/views/about/needToKnow/index.vue'),
meta: { title: '游园需知' }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -204,7 +204,7 @@ const getFileNameFromUrl = (url) => {
}
// http
if (cleanUrl.includes('localhost:3000')) {
if (cleanUrl.includes('127.0.0.1:3000')) {
const matches = cleanUrl.match(/([^/]+)$/)
return matches ? matches[1] : cleanUrl
}
@ -223,14 +223,14 @@ const processImageUrl = (url) => {
if (!url) return ''
// URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/images\/[^/]+$/)) {
if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/images\/[^/]+$/)) {
return url
}
//
const fileName = getFileNameFromUrl(url)
// URL
return `http://localhost:3000/uploads/courses/images/${fileName}`
return `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
}
// URL
@ -238,14 +238,14 @@ const processVideoUrl = (url) => {
if (!url) return ''
// URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/videos\/[^/]+$/)) {
if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/videos\/[^/]+$/)) {
return url
}
//
const fileName = getFileNameFromUrl(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)
//
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 = {
@ -336,7 +336,7 @@ const handleEdit = (row) => {
}
// http
if (cleanUrl.includes('localhost:3000')) {
if (cleanUrl.includes('127.0.0.1:3000')) {
const matches = cleanUrl.match(/([^/]+)$/)
return matches ? matches[1] : cleanUrl
}
@ -355,14 +355,14 @@ const handleEdit = (row) => {
if (!url) return ''
// URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/images\/[^/]+$/)) {
if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/images\/[^/]+$/)) {
return url
}
//
const fileName = getFileNameFromUrl(url)
// URL
return `http://localhost:3000/uploads/courses/images/${fileName}`
return `http://127.0.0.1:3000/uploads/courses/images/${fileName}`
}
// URL
@ -370,14 +370,14 @@ const handleEdit = (row) => {
if (!url) return ''
// URL
if (url.match(/^http:\/\/localhost:3000\/uploads\/courses\/videos\/[^/]+$/)) {
if (url.match(/^http:\/\/127.0.0.1:3000\/uploads\/courses\/videos\/[^/]+$/)) {
return url
}
//
const fileName = getFileNameFromUrl(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,
category: row.category,
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_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_size: row.video_size || 0,
description: row.description || '',
@ -535,7 +535,7 @@ const handleImageError = (e) => {
//
const fileName = getFileNameFromUrl(form.value.cover_image)
// 使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)
}
@ -546,7 +546,7 @@ const handleVideoSuccess = (response) => {
console.log('上传返回的视频信息:', response.data)
//
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 = {
@ -781,7 +781,7 @@ onMounted(() => {
<template v-else>
<el-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"
name="cover_image"
:show-file-list="false"
@ -823,7 +823,7 @@ onMounted(() => {
<template v-else>
<el-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"
name="video"
:show-file-list="false"

View File

@ -44,6 +44,7 @@ const categoryChartRef = ref(null);
let categoryChart = null;
const protectionChartRef = ref(null);
let protectionChart = null;
const trendChartRef = ref(null);
//
const statsCards = ref([
@ -252,7 +253,6 @@ const initProtectionChart = () => {
protectionChart = echarts.init(protectionChartRef.value);
const option = {
title: {
text: "保护等级统计",
textStyle: {
fontSize: 16,
fontWeight: 500,
@ -361,7 +361,6 @@ const initTrendChart = () => {
const myChart = echarts.init(chartDom);
const option = {
title: {
text: "近7天监测数据趋势",
textStyle: {
fontSize: 16,
fontWeight: 500,
@ -412,6 +411,14 @@ const initTrendChart = () => {
},
yAxis: {
type: "value",
max: 50,
interval: 10,
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
lineStyle: {
color: "#EBEEF5",
@ -450,7 +457,7 @@ const initTrendChart = () => {
shadowBlur: 10,
},
},
data: [120, 132, 101, 134, 90, 230, 210],
data: [10, 12, 15, 13, 18, 15, 20],
},
{
name: "监测数据",
@ -479,14 +486,12 @@ const initTrendChart = () => {
shadowBlur: 10,
},
},
data: [220, 182, 191, 234, 290, 330, 310],
data: [15, 18, 12, 20, 15, 18, 16],
},
],
};
myChart.setOption(option);
//
window.addEventListener("resize", () => {
myChart.resize();
});
@ -631,10 +636,10 @@ const fetchStatisticsData = async () => {
onMounted(() => {
initData();
startAutoRefresh();
initTrendChart();
initDistributionChart();
initCategoryChart();
initProtectionChart();
initTrendChart();
initDistributionChart();
window.addEventListener("resize", () => {
categoryChart?.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 {
display: grid;
grid-template-columns: repeat(3, 1fr);
@ -887,10 +831,13 @@ onUnmounted(() => {
margin-bottom: 20px;
.chart-item {
border-radius: 8px;
padding: 20px;
.chart-title {
font-size: 16px;
font-weight: 500;
color: v.$text-primary;
color: #303133;
margin-bottom: 16px;
}

View File

@ -49,6 +49,12 @@ 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 getChartData = async () => {
try {
@ -198,49 +204,147 @@ const showDeviceStatusNotification = (status) => {
})
}
// WebSocket
// WebSocket
const initWebSocket = () => {
ws = new WebSocket('ws://192.168.1.158:6894')
ws.onopen = () => {
console.log('WebSocket连接成功')
if (wsRetryCount >= MAX_RETRY_COUNT) {
console.warn('WebSocket重试次数超过限制切换到HTTP轮询模式')
startPolling()
return
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
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'
if (drone.value.status !== newStatus) {
try {
if (ws) {
ws.close()
ws = null
}
//
ws = new WebSocket('ws://127.0.0.1:6894')
ws.onopen = () => {
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) => {
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()
}
})
}
//
getDroneInfo()
}
} catch (error) {
console.error('WebSocket消息处理错误:', error)
}
} catch (error) {
console.error('WebSocket消息处理错误:', error)
}
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
wsConnected.value = false
wsRetryCount++
ws.onclose = () => {
//
setTimeout(initWebSocket, 5000)
// 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,
@ -249,18 +353,14 @@ const getDroneInfo = async () => {
})
if (res.code === 200) {
const targetDrone = res.data.list.find(item => {
return item.device_code === 'BC13292E5A49914F4D62B9F356E39F56'
})
const targetDrone = res.data.list.find(item =>
item.device_code === 'BC13292E5A49914F4D62B9F356E39F56'
)
if (targetDrone) {
//
// 1. WebSocket
const wsConnected = ws?.readyState === WebSocket.OPEN
// 2. - 使 WebSocket
//
const deviceStatus = targetDrone.status?.code || targetDrone.status
const deviceOnline = wsConnected && (deviceStatus === 1 || (drone.value?.status === 'online'))
// 3.
const deviceOnline = deviceStatus === 1
const newStatus = deviceOnline ? 'online' : 'offline'
//
@ -269,27 +369,29 @@ const getDroneInfo = async () => {
showDeviceStatusNotification(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
//
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 {
drone.value = null
getChartData()
if (drone.value !== null) {
drone.value = null
getChartData()
}
}
} else {
console.error('API响应错误:', res.message)
ElMessage.error(res.message || '获取无人机列表失败')
getChartData()
}
} catch (error) {
console.error('获取无人机列表错误:', error)
ElMessage.error('获取无人机列表失败')
getChartData()
console.error('获取设备信息失败:', error)
if (!drone.value) {
getChartData()
}
} finally {
loading.value = false
}
@ -299,68 +401,125 @@ const getDroneInfo = async () => {
const initVideoPlayer = async () => {
videoLoading.value = true // loading
isPlaying.value = false //
isBuffering.value = false //
//
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
//
if (videoRef.value && drone.value?.status === 'online') {
flvPlayer.value = createFlvPlayer(videoRef.value, drone.value.code)
try {
//
if (flvPlayer.value) {
flvPlayer.value.on('loading', () => {
if (!isPlaying.value) {
videoLoading.value = true
}
})
flvPlayer.value.on('error', (errorType, errorDetail) => {
console.error('视频播放错误:', errorType, errorDetail)
ElMessage.error('视频加载失败,请检查网络连接')
videoLoading.value = false
isPlaying.value = false
})
//
videoRef.value.addEventListener('waiting', () => {
//
isBuffering.value = true
// loading
if (!isPlaying.value) {
videoLoading.value = true
}
})
videoRef.value.addEventListener('playing', () => {
//
videoLoading.value = false
isPlaying.value = true
isBuffering.value = false
})
videoRef.value.addEventListener('canplay', () => {
//
isBuffering.value = false
if (!isPlaying.value) {
videoLoading.value = false
}
})
videoRef.value.addEventListener('error', () => {
console.error('视频播放错误')
videoLoading.value = false
isPlaying.value = false
ElMessage.error('视频加载失败,请检查网络连接')
})
} else {
console.error('播放器创建失败')
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
isPlaying.value = false
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
}
}
@ -368,11 +527,16 @@ const initVideoPlayer = async () => {
let refreshTimer = null
const startRefreshTimer = () => {
refreshTimer = setInterval(() => {
getDroneInfo()
// WebSocketHTTP
if (ws?.readyState !== WebSocket.OPEN) {
getDroneInfo()
}
// 线
if (drone.value?.status !== 'online') {
getChartData()
}
}, 30000) // 30
}, 60000) // 60
}
//
@ -430,14 +594,25 @@ onUnmounted(() => {
document.removeEventListener('click', handleUserInteraction)
document.removeEventListener('keydown', handleUserInteraction)
// WebSocket
// 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)
}
@ -445,6 +620,8 @@ onUnmounted(() => {
chart.dispose()
chart = null
}
wsConnected.value = false
window.removeEventListener('resize', handleResize)
})
</script>

View File

@ -18,6 +18,12 @@ let resizeObserver = null
//
const fetchLatestData = async () => {
try {
//
const chartDom = chartRef.value
if (chartDom) {
chartDom.classList.add('loading')
}
const res = await getLatestData()
if (res.success && res.data) {
monitoringData.value = res.data
@ -25,6 +31,12 @@ const fetchLatestData = async () => {
}
} catch (error) {
console.error('获取最新数据失败:', error)
} finally {
//
const chartDom = chartRef.value
if (chartDom) {
chartDom.classList.remove('loading')
}
}
}
@ -92,73 +104,120 @@ const initChart = async () => {
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
formatter: '{b}: {c}'
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(0, 24, 65, 0.8)',
borderColor: 'rgba(63, 167, 221, 0.2)',
textStyle: {
color: '#fff'
}
},
radar: {
center: ['50%', '50%'],
radius: '65%',
indicator: [],
shape: 'circle',
splitNumber: 8,
axisName: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.2)',
width: 2,
type: 'dashed',
dashOffset: 0
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
}
},
grid: {
top: '15%',
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: [],
axisLine: {
show: true,
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
}
},
series: []
yAxis: [
{
type: 'value',
name: '监测值',
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)',
type: 'dashed'
}
},
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12
}
},
{
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)
//
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
}
@ -172,111 +231,113 @@ const updateChart = () => {
const points = processedData.value
if (points.length === 0) return
//
const indicators = new Set()
const maxValues = {}
//
const allIndicators = new Set()
const indicatorValues = {}
const changeRates = {}
points.forEach(point => {
point.indicators.forEach(indicator => {
indicators.add(indicator.name)
if (!maxValues[indicator.name] || maxValues[indicator.name] < indicator.value) {
maxValues[indicator.name] = indicator.value
allIndicators.add(indicator.name)
if (!indicatorValues[indicator.name]) {
indicatorValues[indicator.name] = []
}
indicatorValues[indicator.name].push(indicator.value)
})
})
//
const radarIndicators = Array.from(indicators).map(name => ({
name: name,
max: Math.ceil(maxValues[name] * 1.2) // 20%
}))
//
allIndicators.forEach(name => {
const values = indicatorValues[name]
if (values.length >= 2) {
const current = values[values.length - 1]
const previous = values[values.length - 2]
changeRates[name] = previous ? ((current - previous) / previous * 100) : 0
} else {
changeRates[name] = 0
}
})
//
const series = [{
type: 'radar',
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
//
const xAxisData = Array.from(allIndicators)
const barData = xAxisData.map(name => {
const values = indicatorValues[name]
return values[values.length - 1] || 0
})
const lineData = xAxisData.map(name => changeRates[name] || 0)
return {
name: point.point,
value: radarIndicators.map(indicator => {
const ind = point.indicators.find(i => i.name === indicator.name)
return ind ? ind.value : 0
}),
symbol: 'circle',
symbolSize: 6,
itemStyle: {
color: colorSet[0],
borderColor: '#fff',
borderWidth: 2,
shadowColor: colorSet[0],
shadowBlur: 10
},
//
chart.setOption({
xAxis: {
data: xAxisData
},
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: {
color: colorSet[0],
width: 2,
type: [5, 10],
shadowColor: colorSet[0],
shadowBlur: 5
width: 3,
color: '#67C23A'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colorSet[0] },
{ offset: 1, color: colorSet[1] }
]),
opacity: 0.5
{ offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
{ offset: 1, color: 'rgba(103, 194, 58, 0.1)' }
])
},
emphasis: {
scale: true,
itemStyle: {
shadowBlur: 20
},
areaStyle: {
opacity: 0.8
}
}
data: lineData.map(() => 0)
}
})
}]
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
//
setTimeout(() => {
chart.setOption({
series: [
{
name: '监测值',
data: barData.map(value => ({
value,
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]
}
}))
}]
})
})),
animationDelay: (idx) => idx * 100,
animationDuration: 1000,
animationEasing: 'cubicInOut'
},
{
name: '变化率',
data: lineData,
animationDelay: (idx) => idx * 100 + 500,
animationDuration: 1000,
animationEasing: 'cubicInOut'
}
]
})
requestAnimationFrame(animate)
}
animate()
}, 100)
}
//
@ -417,6 +478,19 @@ onUnmounted(() => {
.chart {
height: 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; }
100% { opacity: 0.2; }
}
@keyframes pulse {
0% {
opacity: 0.3;
}
50% {
opacity: 0.1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

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

View File

@ -119,36 +119,45 @@ onUnmounted(() => {
top: 50%;
transform: translateY(-50%);
font-family: 'DIN Alternate', 'Arial', sans-serif;
font-size: 24px;
font-weight: 500;
font-size: 28px;
font-weight: 600;
color: #3fa7dd;
text-shadow: 0 0 10px rgba(63, 167, 221, 0.5);
padding: 8px 15px;
border-radius: 4px;
background: rgba(0, 24, 65, 0.3);
border: 1px solid rgba(63, 167, 221, 0.2);
letter-spacing: 1px;
backdrop-filter: blur(4px);
text-shadow: 0 0 15px rgba(63, 167, 221, 0.6);
padding: 12px 24px;
border-radius: 8px;
background: rgba(0, 24, 65, 0.5);
border: 1px solid rgba(63, 167, 221, 0.3);
letter-spacing: 2px;
backdrop-filter: blur(8px);
display: flex;
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 {
content: '';
width: 8px;
height: 8px;
width: 10px;
height: 10px;
background: #3fa7dd;
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 {
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% {
box-shadow: 0 0 0 6px rgba(63, 167, 221, 0);
box-shadow: 0 0 0 10px rgba(63, 167, 221, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(63, 167, 221, 0);

View File

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