Refactor monitoring API and dashboard charts with improved data handling and visualization

This commit is contained in:
wzclm 2025-03-04 17:54:56 +08:00
parent 1265cf7553
commit 4a5e37b5d9
3 changed files with 310 additions and 369 deletions

View File

@ -3,35 +3,40 @@ import request from '@/utils/request'
/**
* 按时间范围查询监测数据
* @param {Object} params - 查询参数
* @param {string} params.start_date - 开始时间
* @param {string} params.end_date - 结束时间
* @param {Array} params.indicator_ids - 指标ID数组
* @param {number} params.point_id - 监测点ID
* @param {number} params.device_id - 设备ID
* @param {string} [params.start_date] - 开始时间格式YYYY-MM-DD
* @param {string} [params.end_date] - 结束时间格式YYYY-MM-DD
* @param {string} [params.indicator_ids] - 指标ID多个用逗号分隔
* @param {string} [params.point_id] - 监测点ID
* @param {string} [params.device_id] - 设备ID
*/
export function getMonitoringData(params) {
return request.get('/api/monitoring/data', { params })
return request.get('/api/monitoring/dataQuery', { params })
}
/**
* 获取最新数据
* @param {Object} params - 查询参数
* @param {number} params.point_id - 监测点ID
* @param {Array} params.indicator_ids - 指标ID数组
* @param {number} params.device_id - 设备ID
* @param {string} [params.point_id] - 监测点ID
* @param {string} [params.indicator_ids] - 指标ID多个用逗号分隔
* @param {string} [params.device_id] - 设备ID
*/
export function getLatestData(params) {
return request.get('/api/monitoring/data/latest', { params })
// 确保 indicator_ids 是字符串格式
const formattedParams = {
...params,
indicator_ids: Array.isArray(params.indicator_ids) ? params.indicator_ids.join(',') : params.indicator_ids
}
return request.get('/api/monitoring/data/latest', { params: formattedParams })
}
/**
* 获取统计数据
* @param {Object} params - 查询参数
* @param {string} params.start_date - 开始时间
* @param {string} params.end_date - 结束时间
* @param {number} params.indicator_id - 指标ID
* @param {number} params.point_id - 监测点ID
* @param {number} params.device_id - 设备ID
* @param {string} [params.start_date] - 开始时间格式YYYY-MM-DD
* @param {string} [params.end_date] - 结束时间格式YYYY-MM-DD
* @param {string} [params.indicator_id] - 指标ID
* @param {string} [params.point_id] - 监测点ID
* @param {string} [params.device_id] - 设备ID
*/
export function getStatistics(params) {
return request.get('/api/monitoring/data/statistics', { params })
@ -40,9 +45,9 @@ export function getStatistics(params) {
/**
* 获取数据质量统计
* @param {Object} params - 查询参数
* @param {string} params.start_date - 开始时间
* @param {string} params.end_date - 结束时间
* @param {number} params.point_id - 监测点ID
* @param {string} [params.start_date] - 开始时间格式YYYY-MM-DD
* @param {string} [params.end_date] - 结束时间格式YYYY-MM-DD
* @param {string} [params.point_id] - 监测点ID
*/
export function getQualityStatistics(params) {
return request.get('/api/monitoring/data/quality-statistics', { params })

View File

@ -13,19 +13,109 @@ const fetchSpeciesData = async () => {
try {
const res = await request.get('/api/admin/species/statistics/overview')
if (res.success && res.data) {
updateSpeciesChart(res.data)
//
const categoryNames = {
bird: '鸟类',
mammal: '哺乳类',
fish: '鱼类',
amphibian: '两栖类',
reptile: '爬行类',
insect: '昆虫类',
plant: '植物'
}
//
const chartData = Object.entries(res.data.categories).map(([key, value]) => ({
name: categoryNames[key],
value: parseInt(value.total_count),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: getSpeciesColor(key)[0] },
{ offset: 1, color: getSpeciesColor(key)[1] }
])
}
})).sort((a, b) => b.value - a.value) //
speciesChart.setOption({
series: [{
data: chartData
}]
})
}
} catch (error) {
console.error('获取物种统计数据失败:', error)
}
}
//
const getSpeciesColor = (category) => {
const colors = {
bird: ['#36CFFF', '#2861F5'],
mammal: ['#FF36D9', '#C92AF5'],
fish: ['#FFB72C', '#F5612A'],
amphibian: ['#4EF568', '#2AB256'],
reptile: ['#36FFB0', '#2AF5A1'],
insect: ['#7636FF', '#2A3CF5'],
plant: ['#FF7636', '#F52A2A']
}
return colors[category] || ['#36CFFF', '#2861F5']
}
//
const fetchPatrolData = async () => {
try {
const res = await request.get('/api/admin/patrol/records/statistics/overview')
if (res.success && res.data) {
updatePatrolChart(res.data)
const { overview } = res.data
const chartData = [
{
name: '已完成',
value: Number(overview.completed_count) || 0,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#67C23A' },
{ offset: 1, color: '#67C23A99' }
])
}
},
{
name: '进行中',
value: Number(overview.in_progress_count) || 0,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#409EFF' },
{ offset: 1, color: '#409EFF99' }
])
}
},
{
name: '总任务',
value: Number(overview.total_count) || 0,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#909399' },
{ offset: 1, color: '#90939999' }
])
}
}
]
patrolChart.setOption({
xAxis: {
data: chartData.map(item => item.name)
},
series: [{
data: chartData,
label: {
show: true,
position: 'top',
color: '#fff',
formatter: function(params) {
return Math.floor(params.value)
}
}
}]
})
}
} catch (error) {
console.error('获取巡护统计数据失败:', error)
@ -39,54 +129,50 @@ const initSpeciesChart = () => {
speciesChart = echarts.init(speciesChartRef.value)
const option = {
backgroundColor: 'transparent',
title: {
text: '物种分布统计',
textStyle: {
color: '#fff',
fontSize: 16
},
left: 'center',
top: 0
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
formatter: function(params) {
const percent = parseInt(params.percent)
return `${params.name}: ${parseInt(params.value)}种 (${percent}%)`
}
},
legend: {
orient: 'vertical',
right: '5%',
top: 'middle',
textStyle: {
color: '#fff'
}
show: false
},
series: [
{
name: '物种分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '55%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderColor: 'rgba(0, 0, 0, 0.2)',
borderWidth: 2
},
label: {
show: true,
position: 'outside',
formatter: function(params) {
return `${params.name}\n${parseInt(params.value)}`
},
color: '#fff',
formatter: '{b}\n{c}种'
fontSize: 12
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
labelLine: {
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
data: []
}
]
}
speciesChart.setOption(option)
}
@ -97,19 +183,13 @@ const initPatrolChart = () => {
patrolChart = echarts.init(patrolChartRef.value)
const option = {
backgroundColor: 'transparent',
title: {
text: '巡护任务统计',
textStyle: {
color: '#fff',
fontSize: 16
},
left: 'center',
top: 0
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
return `${params[0].name}: ${Math.floor(params[0].value)}`
}
},
grid: {
@ -121,7 +201,7 @@ const initPatrolChart = () => {
},
xAxis: {
type: 'category',
data: ['已完成', '进行中', '未开始', '已超时'],
data: ['已完成', '进行中', '总任务'],
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
@ -134,6 +214,9 @@ const initPatrolChart = () => {
},
yAxis: {
type: 'value',
minInterval: 1,
splitNumber: 4,
min: 0,
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)',
@ -141,7 +224,10 @@ const initPatrolChart = () => {
}
},
axisLabel: {
color: '#fff'
color: '#fff',
formatter: function(value) {
return Math.floor(value)
}
}
},
series: [
@ -154,7 +240,10 @@ const initPatrolChart = () => {
label: {
show: true,
position: 'top',
color: '#fff'
color: '#fff',
formatter: function(params) {
return parseInt(params.value || 0) + '个'
}
},
data: []
}
@ -163,62 +252,6 @@ const initPatrolChart = () => {
patrolChart.setOption(option)
}
//
const updateSpeciesChart = (data) => {
const colors = {
'鸟类': '#36CFFF',
'鱼类': '#FFB72C',
'两栖类': '#4EF568',
'爬行类': '#FF36D9',
'哺乳类': '#9E87FF'
}
const chartData = Object.entries(data.category_counts || {}).map(([name, value]) => ({
name,
value,
itemStyle: {
color: colors[name] || '#36CFFF'
}
}))
speciesChart.setOption({
series: [{
data: chartData
}]
})
}
//
const updatePatrolChart = (data) => {
const colors = {
'已完成': '#67C23A',
'进行中': '#409EFF',
'未开始': '#909399',
'已超时': '#F56C6C'
}
const chartData = [
{ name: '已完成', value: data.completed || 0 },
{ name: '进行中', value: data.in_progress || 0 },
{ name: '未开始', value: data.not_started || 0 },
{ name: '已超时', value: data.overdue || 0 }
].map(item => ({
value: item.value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colors[item.name] },
{ offset: 1, color: colors[item.name].replace('FF', '99') }
])
}
}))
patrolChart.setOption({
series: [{
data: chartData
}]
})
}
//
let timer = null
const startAutoRefresh = () => {
@ -295,10 +328,10 @@ onUnmounted(() => {
border-radius: 4px;
padding: 12px;
.chart-title {
font-size: 16px;
.chart-title {
font-size: 16px;
font-weight: bold;
color: #fff;
color: #fff;
text-align: center;
margin-bottom: 12px;
background: linear-gradient(to bottom, #ffffff, #3fa7dd);

View File

@ -1,17 +1,11 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { getMonitoringData, getLatestData, getStatistics, getQualityStatistics } from '@/api/monitoring'
import { getLatestData } from '@/api/monitoring'
//
let trendChart = null
let qualityChart = null
let indicatorChart = null
//
const trendChartRef = ref(null)
const qualityChartRef = ref(null)
const indicatorChartRef = ref(null)
let chart = null
const chartRef = ref(null)
//
const indicatorOptions = [
@ -21,72 +15,111 @@ const indicatorOptions = [
{ label: '浊度', unit: 'NTU', threshold: { min: 0, max: 10 } }
]
//
const updateIndicatorChart = async () => {
if (!indicatorChart || !indicatorChartRef.value) return
//
const initChart = () => {
if (!chartRef.value) return
chart = echarts.init(chartRef.value)
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
top: '10%',
right: '5%',
bottom: '10%',
left: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: [],
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)',
interval: 0
}
},
yAxis: {
type: 'value',
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)'
}
},
series: [
{
type: 'bar',
barWidth: '40%',
itemStyle: {
borderRadius: [4, 4, 0, 0]
},
label: {
show: true,
position: 'top',
color: '#fff'
},
data: []
}
]
})
}
//
const updateChart = async () => {
if (!chart || !chartRef.value) return
try {
const res = await getLatestData({
point_id: 1,
indicator_ids: [1, 2, 3, 4],
device_id: 1
point_id: '1',
indicator_ids: '1,2,3,4',
device_id: '1'
})
if (res.success && res.data) {
const data = res.data.map(item => {
const indicator = indicatorOptions[item.indicator_id - 1]
const indicator = indicatorOptions[Number(item.indicator_id) - 1]
const value = Number(item.value)
const isWarning = value < indicator.threshold.min || value > indicator.threshold.max
return {
name: indicator.label,
value: value,
itemStyle: {
color: isWarning ? '#E6A23C' : '#67C23A'
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isWarning ? '#E6A23C' : '#67C23A' },
{ offset: 1, color: isWarning ? '#F56C6C' : '#95D475' }
])
}
}
})
indicatorChart.setOption({
title: {
text: '实时监测指标',
textStyle: {
color: '#fff',
fontSize: 14
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
chart.setOption({
xAxis: {
type: 'category',
data: data.map(item => item.name),
axisLine: { lineStyle: { color: '#fff' } },
axisLabel: { color: '#fff' }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#fff' } },
axisLabel: { color: '#fff' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
data: data.map(item => item.name)
},
series: [
{
type: 'bar',
data: data,
barWidth: '40%',
label: {
show: true,
position: 'top',
color: '#fff',
formatter: (params) => {
const indicator = indicatorOptions[params.dataIndex]
return params.value + (indicator.unit || '')
@ -101,204 +134,43 @@ const updateIndicatorChart = async () => {
}
}
//
const updateTrendChart = async () => {
if (!trendChart || !trendChartRef.value) return
try {
const now = new Date()
let startDate = new Date()
startDate.setHours(startDate.getHours() - 24)
const res = await getMonitoringData({
start_date: startDate.toISOString(),
end_date: now.toISOString(),
indicator_ids: [1], //
point_id: 1,
device_id: 1
})
if (res.success && res.data) {
const data = res.data.map(item => [
new Date(item.timestamp).getTime(),
item.value
])
trendChart.setOption({
title: {
text: '24小时水温趋势',
textStyle: {
color: '#fff',
fontSize: 14
}
},
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: '#fff' } },
axisLabel: { color: '#fff' }
},
yAxis: {
type: 'value',
name: '°C',
nameTextStyle: { color: '#fff' },
axisLine: { lineStyle: { color: '#fff' } },
axisLabel: { color: '#fff' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
},
series: [{
name: '水温',
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(58,77,233,0.8)' },
{ offset: 1, color: 'rgba(58,77,233,0.1)' }
])
},
data: data
}]
})
}
} catch (error) {
console.error('获取趋势数据失败:', error)
}
}
//
const updateQualityChart = async () => {
if (!qualityChart || !qualityChartRef.value) return
try {
const now = new Date()
let startDate = new Date()
startDate.setDate(startDate.getDate() - 30)
const res = await getQualityStatistics({
start_date: startDate.toISOString(),
end_date: now.toISOString(),
point_id: 1
})
if (res.success && res.data) {
qualityChart.setOption({
title: {
text: '数据质量分析',
textStyle: {
color: '#fff',
fontSize: 14
}
},
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: { color: '#fff' }
},
series: [
{
name: '数据质量',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'inside',
formatter: '{d}%',
fontSize: 12,
color: '#fff'
},
data: [
{ value: res.data.valid_rate, name: '有效数据', itemStyle: { color: '#67C23A' } },
{ value: res.data.invalid_rate, name: '无效数据', itemStyle: { color: '#F56C6C' } },
{ value: res.data.missing_rate, name: '缺失数据', itemStyle: { color: '#E6A23C' } }
]
}
]
})
}
} catch (error) {
console.error('获取数据质量统计失败:', error)
}
}
//
const initCharts = () => {
if (indicatorChartRef.value) {
indicatorChart = echarts.init(indicatorChartRef.value)
}
if (trendChartRef.value) {
trendChart = echarts.init(trendChartRef.value)
}
if (qualityChartRef.value) {
qualityChart = echarts.init(qualityChartRef.value)
}
//
let timer = null
const startAutoRefresh = () => {
updateChart()
timer = setInterval(updateChart, 60000) //
}
//
const handleResize = () => {
indicatorChart?.resize()
trendChart?.resize()
qualityChart?.resize()
}
//
let updateTimer = null
const startDataUpdate = () => {
updateIndicatorChart()
updateTimer = setInterval(() => {
updateIndicatorChart()
}, 60000) //
chart?.resize()
}
onMounted(() => {
initCharts()
startDataUpdate()
updateTrendChart()
updateQualityChart()
initChart()
startAutoRefresh()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (updateTimer) {
clearInterval(updateTimer)
if (timer) {
clearInterval(timer)
}
window.removeEventListener('resize', handleResize)
indicatorChart?.dispose()
trendChart?.dispose()
qualityChart?.dispose()
chart?.dispose()
})
</script>
<template>
<div class="middle-card">
<div class="card-title">监测数据</div>
<div class="card-header">
<div class="title">监测数据</div>
<div class="update-time">实时监测中</div>
</div>
<div class="card-content">
<!-- 实时指标图表 -->
<div ref="indicatorChartRef" class="indicator-chart"></div>
<!-- 趋势图表 -->
<div ref="trendChartRef" class="trend-chart"></div>
<!-- 数据质量图表 -->
<div ref="qualityChartRef" class="quality-chart"></div>
<div class="chart-item">
<div ref="chartRef" class="chart"></div>
</div>
</div>
</div>
</template>
@ -307,36 +179,67 @@ onUnmounted(() => {
.middle-card {
height: 100%;
padding: 16px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
box-sizing: border-box;
background: rgba(6, 30, 93, 0.5);
border-radius: 4px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
font-size: 18px;
font-weight: bold;
background: linear-gradient(to bottom, #ffffff, #3fa7dd);
-webkit-background-clip: text;
color: transparent;
letter-spacing: 2px;
}
.update-time {
font-size: 14px;
color: #3fa7dd;
opacity: 0.8;
position: relative;
padding-left: 20px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: #67C23A;
border-radius: 50%;
animation: blink 1s infinite;
}
}
}
.card-content {
height: calc(100% - 32px);
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100% - 60px);
.indicator-chart {
flex: 1;
min-height: 180px;
}
.chart-item {
height: 100%;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 12px;
.trend-chart {
flex: 2;
min-height: 200px;
}
.quality-chart {
flex: 1;
min-height: 180px;
.chart {
height: 100%;
width: 100%;
}
}
}
}
@keyframes blink {
0% { opacity: 0.2; }
50% { opacity: 1; }
100% { opacity: 0.2; }
}
</style>