fix: 首页数据大屏联调接口
This commit is contained in:
parent
b71a9b398d
commit
859dbbb1c7
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@kjgl77/datav-vue3": "^1.7.4",
|
||||
"axios": "^1.11.0",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-liquidfill": "^3.1.0",
|
||||
"element-plus": "^2.10.4",
|
||||
|
232
web/src/App.vue
232
web/src/App.vue
@ -23,12 +23,17 @@
|
||||
</div>
|
||||
<div class="center-section">
|
||||
<AlarmTrend />
|
||||
<CameraStats />
|
||||
<CameraStats
|
||||
:total-cameras="cameraStats.total_cameras"
|
||||
:online-cameras="cameraStats.online_cameras"
|
||||
:total-algorithms="algorithmStats.total_algorithms"
|
||||
:active-algorithms="algorithmStats.active_algorithms"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-section">
|
||||
<dv-border-box-1>
|
||||
<EventHotSpots />
|
||||
<AlgorithmStats />
|
||||
<EventHotSpots :locations="cameraStats.by_location" />
|
||||
<AlgorithmStats :algorithm-types="algorithmStats.by_type" />
|
||||
</dv-border-box-1>
|
||||
</div>
|
||||
</div>
|
||||
@ -80,13 +85,57 @@ import AlgorithmCenter from './components/algorithm/AlgorithmCenter.vue'
|
||||
import AlarmManagement from './components/alarm/AlarmManagement.vue'
|
||||
import EventCenter from './components/event/EventCenter.vue'
|
||||
import DeviceManagement from './components/device/DeviceManagement.vue'
|
||||
|
||||
import API from '@/api/index'
|
||||
const activeTab = ref('overview')
|
||||
const dashboardContainer = ref(null)
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
const showMonitorDetail = ref(false)
|
||||
|
||||
const cameraStats = ref({
|
||||
total_cameras: 0,
|
||||
online_cameras: 0,
|
||||
offline_cameras: 0,
|
||||
by_location: []
|
||||
})
|
||||
|
||||
const algorithmStats = ref({
|
||||
total_algorithms: 0,
|
||||
active_algorithms: 0,
|
||||
by_type: []
|
||||
})
|
||||
|
||||
// 获取摄像头统计数据
|
||||
const fetchCameraStats = async () => {
|
||||
try {
|
||||
const response = await API.getCameraStats()
|
||||
cameraStats.value = response
|
||||
} catch (error) {
|
||||
console.error('获取摄像头统计数据失败:', error)
|
||||
cameraStats.value = {
|
||||
total_cameras: 0,
|
||||
online_cameras: 0,
|
||||
offline_cameras: 0,
|
||||
by_location: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取算法统计数据
|
||||
const fetchAlgorithmStats = async () => {
|
||||
try {
|
||||
const response = await API.getAlgorithmStats()
|
||||
algorithmStats.value = response
|
||||
} catch (error) {
|
||||
console.error('获取算法统计数据失败:', error)
|
||||
algorithmStats.value = {
|
||||
total_algorithms: 0,
|
||||
active_algorithms: 0,
|
||||
by_type: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentMonitor = ref({
|
||||
id: 1,
|
||||
name: '港口区主监控',
|
||||
@ -94,91 +143,95 @@ const currentMonitor = ref({
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-main.mp4'
|
||||
})
|
||||
const monitors = ref([])
|
||||
// const monitors = ref([
|
||||
// {
|
||||
// id: 1,
|
||||
// name: '港口区监控1',
|
||||
// location: '港口A区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-1.mp4',
|
||||
// detections: [
|
||||
// { type: 'person', x: 25, y: 35, width: 40, height: 80 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 4,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 5,
|
||||
// name: '港口区监控1',
|
||||
// location: '港口A区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-1.mp4',
|
||||
// detections: [
|
||||
// { type: 'person', x: 25, y: 35, width: 40, height: 80 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 6,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 7,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 8,
|
||||
// name: '港口区监控2',
|
||||
// location: '港口B区',
|
||||
// status: 'online',
|
||||
// videoSrc: '/videos/port-2.mp4',
|
||||
// detections: [
|
||||
// { type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
// ]
|
||||
// },
|
||||
|
||||
const monitors = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '港口区监控1',
|
||||
location: '港口A区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-1.mp4',
|
||||
detections: [
|
||||
{ type: 'person', x: 25, y: 35, width: 40, height: 80 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '港口区监控1',
|
||||
location: '港口A区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-1.mp4',
|
||||
detections: [
|
||||
{ type: 'person', x: 25, y: 35, width: 40, height: 80 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '港口区监控2',
|
||||
location: '港口B区',
|
||||
status: 'online',
|
||||
videoSrc: '/videos/port-2.mp4',
|
||||
detections: [
|
||||
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
|
||||
]
|
||||
},
|
||||
|
||||
])
|
||||
|
||||
// ])
|
||||
// 获取列表
|
||||
const getMonitorsList = async () => {
|
||||
const list = await API.getMonitorsList()
|
||||
monitors.value = list.monitors || []
|
||||
}
|
||||
|
||||
// 切换标签
|
||||
const handleTabChange = (tab) => {
|
||||
@ -217,6 +270,9 @@ const handleFullscreenChange = () => {
|
||||
|
||||
// 添加事件监听
|
||||
onMounted(() => {
|
||||
getMonitorsList()
|
||||
fetchCameraStats()
|
||||
fetchAlgorithmStats()
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
|
@ -1,11 +1,89 @@
|
||||
import http from '../utils/http'
|
||||
|
||||
export default {
|
||||
login(username, password) {
|
||||
return http.post('/user/login', { username, password })
|
||||
getKpi() {
|
||||
return http.get('/dashboard/kpi')
|
||||
},
|
||||
|
||||
getInfo() {
|
||||
return http.get('/user/info')
|
||||
}
|
||||
|
||||
getAlarmTrend() {
|
||||
return http.get('/dashboard/alarm-trend')
|
||||
},
|
||||
|
||||
getCameraStats() {
|
||||
return http.get('/dashboard/camera-stats')
|
||||
},
|
||||
|
||||
getAlgorithmStats() {
|
||||
return http.get('/dashboard/algorithm-stats')
|
||||
},
|
||||
|
||||
getMonitorsList(params) {
|
||||
return http.get('/monitors/', params)
|
||||
},
|
||||
getMonitorsDetail(monitor_id) {
|
||||
return http.get(`/api/monitors/${monitor_id}`)
|
||||
},
|
||||
// 获取场景列表
|
||||
getScenesList(params) {
|
||||
return http.get('/scenes/', params)
|
||||
},
|
||||
|
||||
// 添加场景
|
||||
addScenes(data) {
|
||||
return http.post('/scenes/', data)
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 设备管理接口
|
||||
*/
|
||||
|
||||
// 添加设备
|
||||
addDevices(data) {
|
||||
return http.post('/devices/', data)
|
||||
},
|
||||
// 获取设备列表
|
||||
getDevices(params) {
|
||||
return http.get('/devices/', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传视频文件
|
||||
*/
|
||||
|
||||
/**
|
||||
* 上传视频文件(二进制格式)
|
||||
* @param {File} file - 视频文件
|
||||
* @param {string} deviceId - 设备ID
|
||||
* @param {string} [description] - 视频描述
|
||||
* @returns {Promise}
|
||||
*/
|
||||
uploadVideo (file, deviceId, description = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
// 读取文件为二进制字符串
|
||||
reader.readAsBinaryString(file)
|
||||
|
||||
reader.onload = () => {
|
||||
const binaryString = reader.result
|
||||
|
||||
request({
|
||||
url: '/upload/video',
|
||||
method: 'post',
|
||||
data: {
|
||||
file: binaryString, // 二进制字符串
|
||||
device_id: deviceId,
|
||||
description: description
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json' // 使用JSON格式
|
||||
}
|
||||
}).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
reader.onerror = error => reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -10,12 +10,57 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import API from '@/api/index'
|
||||
|
||||
const chart = ref(null)
|
||||
let myChart = null
|
||||
|
||||
const fetchAlarmTrend = async () => {
|
||||
try {
|
||||
const response = await API.getAlarmTrend()
|
||||
const trendData = response
|
||||
|
||||
// 格式化月份数据,只显示月份部分
|
||||
const formattedMonths = trendData.months.map(month => {
|
||||
return month.split('-')[1] + '月'
|
||||
})
|
||||
|
||||
// 更新图表选项
|
||||
const option = {
|
||||
xAxis: {
|
||||
data: formattedMonths
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'P0告警',
|
||||
data: trendData.p0_warnings
|
||||
},
|
||||
{
|
||||
name: 'P1告警',
|
||||
data: trendData.p1_warnings
|
||||
},
|
||||
{
|
||||
name: 'P2告警',
|
||||
data: trendData.p2_warnings
|
||||
},
|
||||
{
|
||||
name: 'P3告警',
|
||||
data: trendData.p3_warnings
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
myChart.setOption(option)
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取告警趋势数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const myChart = echarts.init(chart.value)
|
||||
myChart = echarts.init(chart.value)
|
||||
|
||||
// 初始化默认选项
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
@ -44,7 +89,7 @@ onMounted(() => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['1月', '2月', '3月', '4月', '5月'],
|
||||
data: [], // 初始为空,将从API获取
|
||||
axisLine: { lineStyle: { color: 'rgba(124, 208, 255, 0.5)' } },
|
||||
axisLabel: {
|
||||
color: 'rgba(124, 208, 255, 0.8)',
|
||||
@ -55,8 +100,8 @@ onMounted(() => {
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 550,
|
||||
interval: 50,
|
||||
max: 60, // 根据数据范围调整
|
||||
interval: 10,
|
||||
axisLine: {
|
||||
lineStyle: { color: 'rgba(124, 208, 255, 0.5)' }
|
||||
},
|
||||
@ -84,7 +129,7 @@ onMounted(() => {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
data: [300, 100, 400, 250, 200]
|
||||
data: [] // 初始为空,将从API获取
|
||||
},
|
||||
{
|
||||
name: 'P1告警',
|
||||
@ -98,7 +143,7 @@ onMounted(() => {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
data: [300, 100, 400, 250, 200]
|
||||
data: [] // 初始为空,将从API获取
|
||||
},
|
||||
{
|
||||
name: 'P2告警',
|
||||
@ -112,7 +157,7 @@ onMounted(() => {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
data: [350, 150, 550, 50, 110]
|
||||
data: [] // 初始为空,将从API获取
|
||||
},
|
||||
{
|
||||
name: 'P3告警',
|
||||
@ -126,13 +171,16 @@ onMounted(() => {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
data: [200, 300, 400, 100, 50]
|
||||
data: [] // 初始为空,将从API获取
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
myChart.setOption(option)
|
||||
|
||||
// 获取数据
|
||||
fetchAlarmTrend()
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
myChart.resize()
|
||||
})
|
||||
@ -142,17 +190,16 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.alert-trend-container {
|
||||
width: 100%;
|
||||
height: 50vh; /* 增加高度 */
|
||||
height: 50vh;
|
||||
padding: 15px 10px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 50px; /* 增加上方间距 */
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.border-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
/* padding: 20px; */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
@ -8,12 +8,75 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const radarChart = ref(null)
|
||||
const props = defineProps({
|
||||
algorithmTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const radarChart = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
// 颜色数组
|
||||
const colors = [
|
||||
{ line: '#A020F0', area: 'rgba(160, 32, 240, 0.2)' }, // 紫色
|
||||
{ line: '#1E90FF', area: 'rgba(30, 144, 255, 0.2)' }, // 蓝色
|
||||
{ line: '#FF6347', area: 'rgba(255, 99, 71, 0.2)' }, // 红色
|
||||
{ line: '#32CD32', area: 'rgba(50, 205, 50, 0.2)' }, // 绿色
|
||||
{ line: '#FFD700', area: 'rgba(255, 215, 0, 0.2)' } // 金色
|
||||
]
|
||||
|
||||
const initChart = () => {
|
||||
if (!radarChart.value) return
|
||||
|
||||
chartInstance = echarts.init(radarChart.value)
|
||||
|
||||
// 固定雷达图维度
|
||||
const indicators = [
|
||||
{ name: '命中率', max: 100 },
|
||||
{ name: '误报率', max: 100 },
|
||||
{ name: '触发器', max: 100 },
|
||||
{ name: '处理时延', max: 100 },
|
||||
{ name: '稳定性', max: 100 }
|
||||
]
|
||||
|
||||
// 根据算法类型生成系列数据
|
||||
const seriesData = props.algorithmTypes.map((item, index) => {
|
||||
// 计算各维度值
|
||||
const values = [
|
||||
item.accuracy, // 命中率 = 准确率
|
||||
100 - item.accuracy, // 误报率 = 100 - 准确率
|
||||
Math.min(100, item.count), // 触发器 = count * 15 (最大100)
|
||||
100 - Math.round(item.accuracy / 1.5), // 处理时延 = 反比于准确率
|
||||
90 + Math.round(item.accuracy / 10) // 稳定性 = 90 + 准确率/10
|
||||
]
|
||||
|
||||
const colorIndex = index % colors.length
|
||||
|
||||
return {
|
||||
value: values,
|
||||
name: item.type,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: colors[colorIndex].line
|
||||
},
|
||||
itemStyle: {
|
||||
color: colors[colorIndex].line,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: colors[colorIndex].area
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const radarOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
@ -24,10 +87,20 @@ onMounted(() => {
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
},
|
||||
formatter: params => {
|
||||
return `
|
||||
<div style="font-weight:bold">${params.name}</div>
|
||||
<div>命中率: ${params.value[0].toFixed(1)}%</div>
|
||||
<div>误报率: ${params.value[1].toFixed(1)}%</div>
|
||||
<div>触发器: ${params.value[2].toFixed(0)}</div>
|
||||
<div>处理时延: ${params.value[3].toFixed(0)}ms</div>
|
||||
<div>稳定性: ${params.value[4].toFixed(0)}%</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['目标识别算法', '行为识别算法'],
|
||||
data: props.algorithmTypes.map(item => item.type),
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
@ -38,13 +111,7 @@ onMounted(() => {
|
||||
itemGap: 20
|
||||
},
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '命中率', max: 100 },
|
||||
{ name: '误报率', max: 100 },
|
||||
{ name: '触发器', max: 100 },
|
||||
{ name: '处理时延', max: 100 },
|
||||
{ name: '稳定性', max: 100 }
|
||||
],
|
||||
indicator: indicators,
|
||||
radius: '65%',
|
||||
axisName: {
|
||||
color: 'rgba(124, 208, 255, 0.8)',
|
||||
@ -69,60 +136,32 @@ onMounted(() => {
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: [90, 85, 95, 80, 88],
|
||||
name: '目标识别算法',
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#A020F0' // 紫色
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#A020F0',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(160, 32, 240, 0.2)'
|
||||
}
|
||||
},
|
||||
{
|
||||
value: [80, 75, 85, 70, 82],
|
||||
name: '行为识别算法',
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#1E90FF' // 蓝色
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#1E90FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(30, 144, 255, 0.2)'
|
||||
}
|
||||
}
|
||||
]
|
||||
data: seriesData
|
||||
}]
|
||||
}
|
||||
|
||||
const chartInstance = echarts.init(radarChart.value)
|
||||
chartInstance.setOption(radarOption)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
chartInstance.resize()
|
||||
chartInstance && chartInstance.resize()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.algorithmTypes, () => {
|
||||
if (chartInstance) {
|
||||
initChart()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.radar-chart-container {
|
||||
width: 100%;
|
||||
height: 40vh; /* 高度占一半 */
|
||||
height: 40vh;
|
||||
padding: 15px 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -131,7 +170,6 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
/* padding: 20px; */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
@ -5,12 +5,12 @@
|
||||
<div class="gauge-container">
|
||||
<div class="gauge">
|
||||
<div ref="onlineRateChart" class="gauge-chart"></div>
|
||||
<div class="gauge-value">58%</div>
|
||||
<div class="gauge-value">{{ onlineRate }}%</div>
|
||||
<div class="gauge-label">摄像头在线率</div>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div ref="coverageChart" class="gauge-chart"></div>
|
||||
<div class="gauge-value">40%</div>
|
||||
<div class="gauge-value">{{ coverageRate }}%</div>
|
||||
<div class="gauge-label">算法部署覆盖率</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -19,59 +19,115 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import 'echarts-liquidfill' // 引入水波图扩展
|
||||
import 'echarts-liquidfill'
|
||||
|
||||
const props = defineProps({
|
||||
totalCameras: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
onlineCameras: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalAlgorithms: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
activeAlgorithms: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const onlineRateChart = ref(null)
|
||||
const coverageChart = ref(null)
|
||||
|
||||
// 计算摄像头在线率
|
||||
const onlineRate = computed(() => {
|
||||
if (props.totalCameras === 0) return 0
|
||||
return Math.round((props.onlineCameras / props.totalCameras) * 100)
|
||||
})
|
||||
|
||||
// 计算算法部署覆盖率
|
||||
const coverageRate = computed(() => {
|
||||
if (props.totalAlgorithms === 0) return 0
|
||||
return Math.round((props.activeAlgorithms / props.totalAlgorithms) * 100)
|
||||
})
|
||||
|
||||
// 水波图配置
|
||||
const createLiquidOption = (value, color) => ({
|
||||
series: [{
|
||||
type: 'liquidFill',
|
||||
radius: '70%',
|
||||
center: ['50%', '50%'],
|
||||
data: [value / 100 * 0.9, value / 100 * 0.7],
|
||||
color: [color],
|
||||
backgroundStyle: {
|
||||
color: 'rgba(16, 42, 87, 0.5)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124, 208, 255, 0.3)'
|
||||
},
|
||||
outline: { show: false },
|
||||
label: { show: false },
|
||||
waveAnimation: true,
|
||||
animationDuration: 2000,
|
||||
animationEasing: 'linear',
|
||||
amplitude: 6,
|
||||
waveLength: '80%'
|
||||
}]
|
||||
})
|
||||
|
||||
// 更新图表
|
||||
const updateCharts = () => {
|
||||
if (onlineRateChart.value && coverageChart.value) {
|
||||
const onlineRateChartInstance = echarts.getInstanceByDom(onlineRateChart.value) ||
|
||||
echarts.init(onlineRateChart.value)
|
||||
onlineRateChartInstance.setOption(createLiquidOption(onlineRate.value, '#3A8BFF'))
|
||||
|
||||
const coverageChartInstance = echarts.getInstanceByDom(coverageChart.value) ||
|
||||
echarts.init(coverageChart.value)
|
||||
coverageChartInstance.setOption(createLiquidOption(coverageRate.value, '#FFA726'))
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化
|
||||
watch([onlineRate, coverageRate], () => {
|
||||
updateCharts()
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
onMounted(() => {
|
||||
// 水波图配置
|
||||
const createLiquidOption = (color) => ({
|
||||
series: [{
|
||||
type: 'liquidFill',
|
||||
radius: '70%',
|
||||
center: ['50%', '50%'],
|
||||
data: [0.338, 0.48], // 水波高度
|
||||
color: [color],
|
||||
backgroundStyle: {
|
||||
color: 'rgba(16, 42, 87, 0.5)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124, 208, 255, 0.3)'
|
||||
},
|
||||
outline: {
|
||||
show: false
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
waveAnimation: true,
|
||||
animationDuration: 2000,
|
||||
animationEasing: 'linear',
|
||||
amplitude: 6,
|
||||
waveLength: '80%'
|
||||
}]
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
const onlineRate = echarts.init(onlineRateChart.value)
|
||||
onlineRate.setOption(createLiquidOption('#3A8BFF'))
|
||||
|
||||
const coverage = echarts.init(coverageChart.value)
|
||||
coverage.setOption(createLiquidOption('#FFA726'))
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
onlineRate.resize()
|
||||
coverage.resize()
|
||||
})
|
||||
updateCharts()
|
||||
|
||||
const resizeHandler = () => {
|
||||
[onlineRateChart.value, coverageChart.value].forEach(chart => {
|
||||
if (chart) {
|
||||
const instance = echarts.getInstanceByDom(chart)
|
||||
instance && instance.resize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
[onlineRateChart.value, coverageChart.value].forEach(chart => {
|
||||
if (chart) {
|
||||
const instance = echarts.getInstanceByDom(chart)
|
||||
instance && instance.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式保持不变 */
|
||||
.stats-container {
|
||||
|
||||
height: 30vh;
|
||||
box-sizing: border-box;
|
||||
padding: 15px 10px;
|
||||
|
@ -8,12 +8,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const barChart = ref(null)
|
||||
const props = defineProps({
|
||||
locations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const barChart = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!barChart.value) return
|
||||
|
||||
chartInstance = echarts.init(barChart.value)
|
||||
|
||||
const barOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
@ -25,9 +37,9 @@ onMounted(() => {
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
legend: {
|
||||
data: ['检测数据1', '检测数据2'],
|
||||
bottom: 10, // 距离底部10px
|
||||
left: 'center', // 水平居中
|
||||
data: ['摄像头总数', '在线数量'],
|
||||
bottom: 10,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
@ -39,15 +51,15 @@ onMounted(() => {
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '4%',
|
||||
bottom: '15%', // 增加底部间距给图例留空间
|
||||
bottom: '15%',
|
||||
top: '5%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 250,
|
||||
interval: 50,
|
||||
max: Math.max(...props.locations.map(l => l.total)) + 10,
|
||||
interval: Math.ceil(Math.max(...props.locations.map(l => l.total)) / 5),
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(124, 208, 255, 0.5)'
|
||||
@ -66,13 +78,7 @@ onMounted(() => {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: [
|
||||
'办公走廊/楼道区',
|
||||
'会议室区域',
|
||||
'值班岗亭区',
|
||||
'边检通道区',
|
||||
'港口泊位区'
|
||||
],
|
||||
data: props.locations.map(l => l.location),
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(124, 208, 255, 0.5)'
|
||||
@ -88,7 +94,7 @@ onMounted(() => {
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '检测数据1',
|
||||
name: '摄像头总数',
|
||||
type: 'bar',
|
||||
barWidth: 15,
|
||||
label: {
|
||||
@ -102,10 +108,10 @@ onMounted(() => {
|
||||
color: '#FFA500',
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
},
|
||||
data: [120, 150, 200, 100, 150]
|
||||
data: props.locations.map(l => l.total)
|
||||
},
|
||||
{
|
||||
name: '检测数据2',
|
||||
name: '在线数量',
|
||||
type: 'bar',
|
||||
barWidth: 15,
|
||||
label: {
|
||||
@ -119,24 +125,33 @@ onMounted(() => {
|
||||
color: '#00FFC0',
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
},
|
||||
data: [100, 120, 100, 80, 100]
|
||||
data: props.locations.map(l => l.online)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const chartInstance = echarts.init(barChart.value)
|
||||
chartInstance.setOption(barOption)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
chartInstance.resize()
|
||||
chartInstance && chartInstance.resize()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.locations, () => {
|
||||
if (chartInstance) {
|
||||
initChart()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.half-height-chart-container {
|
||||
width: 100%;
|
||||
height: 40vh; /* 关键修改:高度设为视口高度的50% */
|
||||
height: 40vh;
|
||||
padding: 15px 30px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 20px;
|
||||
@ -146,7 +161,6 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
/* padding: 20px; */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
@ -2,27 +2,38 @@
|
||||
<div class="kpi-container">
|
||||
<dv-border-box-10 class="kpi-card">
|
||||
<div class="chart-title">今日告警</div>
|
||||
<div class="kpi-value">6549.69<span class="monitor-unit">个</span></div>
|
||||
<div class="kpi-value">{{ formatNumber(kpiData.alert_events) }}<span class="monitor-unit">个</span></div>
|
||||
</dv-border-box-10>
|
||||
<dv-border-box-10 class="kpi-card online-monitor">
|
||||
<div class="chart-title">在线监控数</div>
|
||||
<div class="kpi-value">
|
||||
<dv-border-box-12 class="monitor-box">
|
||||
<div class="monitor-title">当前在线监控</div>
|
||||
<div class="monitor-value">13,365<span class="monitor-unit">台</span></div>
|
||||
<div class="monitor-value">{{ formatNumber(kpiData.online_devices) }}<span class="monitor-unit">台</span></div>
|
||||
</dv-border-box-12>
|
||||
</div>
|
||||
</dv-border-box-10>
|
||||
<dv-border-box-10 class="kpi-card algorithm-card">
|
||||
<div class="chart-title">算法执行</div>
|
||||
<div class="algorithm-content">
|
||||
<div class="algorithm-subtitle">累计执行次数</div>
|
||||
<div class="algorithm-subtitle">活跃算法数量</div>
|
||||
<div class="flip-counter">
|
||||
<div class="flip-digit" v-for="(digit, index) in digits" :key="index">
|
||||
<div class="digit-scroll" :style="{ transform: `translateY(${-digit.current * 10}%)` }">
|
||||
<span v-for="n in 10" :key="n">{{ n }}</span>
|
||||
<template v-if="algorithmDigits.length === 1 && algorithmDigits[0].current === 0">
|
||||
<!-- 单独处理0的情况 -->
|
||||
<div class="flip-digit">
|
||||
<div class="digit-scroll" style="transform: translateY(0%)">
|
||||
<span>0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 正常数字显示 -->
|
||||
<div class="flip-digit" v-for="(digit, index) in algorithmDigits" :key="index">
|
||||
<div class="digit-scroll" :style="{ transform: `translateY(${-digit.current * 10}%)` }">
|
||||
<span v-for="n in 10" :key="n">{{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</dv-border-box-10>
|
||||
@ -31,56 +42,82 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import API from '@/api/index'
|
||||
|
||||
// 初始显示22415
|
||||
const digits = ref([
|
||||
{ current: 2, target: 2 },
|
||||
{ current: 2, target: 2 },
|
||||
{ current: 4, target: 4 },
|
||||
{ current: 1, target: 6 },
|
||||
{ current: 5, target: 4 },
|
||||
{ current: 5, target: 4 }
|
||||
])
|
||||
// 定义响应式数据
|
||||
const kpiData = ref({
|
||||
total_devices: 0,
|
||||
online_devices: 0,
|
||||
total_algorithms: 0,
|
||||
active_algorithms: 0,
|
||||
total_events: 0,
|
||||
today_events: 0,
|
||||
alert_events: 0,
|
||||
resolved_events: 0
|
||||
})
|
||||
|
||||
const animateDigits = () => {
|
||||
// 3秒后开始变化到22464
|
||||
setTimeout(() => {
|
||||
digits.value.forEach((digit, index) => {
|
||||
setTimeout(() => {
|
||||
let steps = Math.abs(digit.target - digit.current)
|
||||
if (steps > 5) steps = 10 - steps
|
||||
|
||||
const duration = 1000
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
digit.current = Math.floor(digit.current + progress * (digit.target - digit.current))
|
||||
if (digit.current > 9) digit.current = 0
|
||||
if (digit.current < 0) digit.current = 9
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
digit.current = digit.target
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}, index * 300)
|
||||
})
|
||||
}, 1000)
|
||||
// 初始化数字翻牌器
|
||||
const algorithmDigits = ref([])
|
||||
|
||||
// 格式化数字显示(添加千位分隔符)
|
||||
const formatNumber = (num) => {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
}
|
||||
|
||||
// 将数字转换为数字数组,用于翻牌器
|
||||
const numberToDigits = (number) => {
|
||||
if (number === 0) {
|
||||
return [{ current: 0, target: 0 }]
|
||||
}
|
||||
|
||||
const numStr = number.toString()
|
||||
return numStr.split('').map(d => ({
|
||||
current: parseInt(d),
|
||||
target: parseInt(d)
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新数字翻牌器
|
||||
const updateDigits = (targetNumber) => {
|
||||
algorithmDigits.value = numberToDigits(targetNumber)
|
||||
}
|
||||
|
||||
// 获取KPI数据
|
||||
const fetchKpiData = async () => {
|
||||
try {
|
||||
const response = await API.getKpi()
|
||||
kpiData.value = response
|
||||
|
||||
// 特殊处理0的情况
|
||||
if (kpiData.value.active_algorithms === 0) {
|
||||
algorithmDigits.value = [{ current: 0, target: 0}]
|
||||
} else {
|
||||
updateDigits(kpiData.value.active_algorithms)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取KPI数据失败:', error)
|
||||
// 设置默认值
|
||||
kpiData.value = {
|
||||
total_devices: 0,
|
||||
online_devices: 0,
|
||||
total_algorithms: 0,
|
||||
active_algorithms: 0,
|
||||
total_events: 0,
|
||||
today_events: 0,
|
||||
alert_events: 0,
|
||||
resolved_events: 0
|
||||
}
|
||||
algorithmDigits.value = [{ current: 0, target: 0 }]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateDigits()
|
||||
fetchKpiData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 基础样式 */
|
||||
.kpi-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -94,10 +131,6 @@ onMounted(() => {
|
||||
width: 91%;
|
||||
height: 25vh;
|
||||
margin: 10px 0;
|
||||
/* display: flex; */
|
||||
/* flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center; */
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
@ -208,6 +241,7 @@ onMounted(() => {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1000%; /* 10个数字的高度 */
|
||||
transition: transform 0.8s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
@ -220,4 +254,10 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 单独为0设置的样式 */
|
||||
.flip-digit .digit-scroll[style*="translateY(0%)"] span {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
</style>
|
@ -1,5 +1,5 @@
|
||||
// src/utils/http.js
|
||||
import axios from '../config/service' // 基础配置
|
||||
import axios from './service' // 基础配置
|
||||
|
||||
/**
|
||||
* 高级请求封装
|
||||
|
@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
|
||||
// 创建 Axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // 从环境变量读取
|
||||
baseURL: '/api', // 从环境变量读取
|
||||
timeout: 10000, // 请求超时时间
|
||||
});
|
||||
|
||||
|
@ -7,7 +7,13 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0', // 监听所有网络接口
|
||||
port: 5173, // 默认端口
|
||||
strictPort: true // 如果端口被占用则退出
|
||||
strictPort: true, // 如果端口被占用则退出
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://36.103.203.89:6789',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user