完成可视化大屏的无人机实时监控和图表的切换,还有ai巡护的摄像头管理以及无人机管理页面。

This commit is contained in:
wzclm 2025-02-28 15:45:40 +08:00
parent 2545bf43ac
commit d115627cd0
25 changed files with 3041 additions and 292 deletions

168
package-lock.json generated
View File

@ -8,10 +8,14 @@
"name": "wetlandguard-admin",
"version": "0.0.0",
"dependencies": {
"@dataview/datav-vue3": "^0.0.0-test.1672506674342",
"@element-plus/icons-vue": "^2.3.1",
"@kjgl77/datav-vue3": "^1.7.4",
"axios": "^1.7.9",
"datav-vue3": "^1.0.0",
"echarts": "^5.6.0",
"element-plus": "^2.9.3",
"flv.js": "^1.6.2",
"json-server": "^1.0.0-beta.3",
"pinia": "^2.3.1",
"vue": "^3.5.13",
@ -61,6 +65,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.26.7",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.26.7.tgz",
@ -83,6 +98,27 @@
"node": ">=10"
}
},
"node_modules/@dataview/datav-vue3": {
"version": "0.0.0-test.1672506674342",
"resolved": "https://registry.npmmirror.com/@dataview/datav-vue3/-/datav-vue3-0.0.0-test.1672506674342.tgz",
"integrity": "sha512-d0oT/msAi592CTvWmQl0umkLpHgMwtTN2+peyo0L2GHNG7b4cKeO9meEF5o28DgFzRwOLeNQW73vKCF4JC+ihw==",
"dependencies": {
"@jiaminghi/color": "^0.1.1",
"classnames": "^2.3.2",
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/@dataview/datav-vue3/node_modules/@jiaminghi/color": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/@jiaminghi/color/-/color-0.1.1.tgz",
"integrity": "sha512-M09+Sb5HGqVim0zo+nG5gU1v+6gXT8ptr0BZR6dMGt83XmCJgnZtO8s7llTW4hLFFFM5co6geZvTekqLpSPAAQ==",
"dependencies": {
"@babel/runtime": "^7.5.5"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
@ -542,12 +578,102 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@jiaminghi/bezier-curve": {
"version": "0.0.9",
"resolved": "https://registry.npmmirror.com/@jiaminghi/bezier-curve/-/bezier-curve-0.0.9.tgz",
"integrity": "sha512-u9xJPOEl6Dri2E9FfmJoGxYQY7vYJkURNX04Vj64tdi535tPrpkuf9Sm0lNr3QTKdHQh0DdNRsaa62FLQNQEEw==",
"dependencies": {
"@babel/runtime": "^7.5.5"
}
},
"node_modules/@jiaminghi/c-render": {
"version": "0.4.3",
"resolved": "https://registry.npmmirror.com/@jiaminghi/c-render/-/c-render-0.4.3.tgz",
"integrity": "sha512-FJfzj5hGj7MLqqqI2D7vEzHKbQ1Ynnn7PJKgzsjXaZpJzTqs2Yw5OSeZnm6l7Qj7jyPAP53lFvEQNH4o4j6s+Q==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"@jiaminghi/bezier-curve": "*",
"@jiaminghi/color": "*",
"@jiaminghi/transition": "*"
}
},
"node_modules/@jiaminghi/charts": {
"version": "0.2.18",
"resolved": "https://registry.npmmirror.com/@jiaminghi/charts/-/charts-0.2.18.tgz",
"integrity": "sha512-K+HXaOOeWG9OOY1VG6M4mBreeeIAPhb9X+khG651AbnwEwL6G2UtcAQ8GWCq6GzhczcLwwhIhuaHqRygwHC0sA==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"@jiaminghi/c-render": "^0.4.3"
}
},
"node_modules/@jiaminghi/color": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@jiaminghi/color/-/color-1.1.3.tgz",
"integrity": "sha512-ZY3hdorgODk4OSTbxyXBPxAxHPIVf9rPlKJyK1C1db46a50J0reFKpAvfZG8zMG3lvM60IR7Qawgcu4ZDO3+Hg=="
},
"node_modules/@jiaminghi/transition": {
"version": "1.1.11",
"resolved": "https://registry.npmmirror.com/@jiaminghi/transition/-/transition-1.1.11.tgz",
"integrity": "sha512-owBggipoHMikDHHDW5Gc7RZYlVuvxHADiU4bxfjBVkHDAmmck+fCkm46n2JzC3j33hWvP9nSCAeh37t6stgWeg==",
"dependencies": {
"@babel/runtime": "^7.5.5"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT"
},
"node_modules/@kjgl77/datav-vue3": {
"version": "1.7.4",
"resolved": "https://registry.npmmirror.com/@kjgl77/datav-vue3/-/datav-vue3-1.7.4.tgz",
"integrity": "sha512-zYVTVKkklUxwtiNKS1qPBilm4rTW+WItfp0zVpaRAI8wgXkLSPbDR9xPq2+UcU/Jft7/DVdMfBp709E2ResuPQ==",
"dependencies": {
"@jiaminghi/c-render": "^0.4.3",
"@jiaminghi/charts": "^0.2.18",
"@jiaminghi/color": "^1.1.3",
"@vueuse/core": "^10.11.1"
}
},
"node_modules/@kjgl77/datav-vue3/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
},
"node_modules/@kjgl77/datav-vue3/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@kjgl77/datav-vue3/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@kjgl77/datav-vue3/node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz",
@ -1745,6 +1871,11 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz",
@ -1769,6 +1900,19 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/datav-vue3": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/datav-vue3/-/datav-vue3-1.0.0.tgz",
"integrity": "sha512-ehQgoAxyZHZwLtZBJ8mlEe41bOjGH816bPH0XhtjR6saVYUgt/HUslcE2M18lm0FfmTlLOcvPtvoDBlitIyehg==",
"dependencies": {
"@jiaminghi/color": "1.1.3",
"classnames": "^2.3.2",
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
@ -1877,6 +2021,11 @@
"node": ">=12.x"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmmirror.com/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.24.2.tgz",
@ -1956,6 +2105,15 @@
"node": ">=8"
}
},
"node_modules/flv.js": {
"version": "1.6.2",
"resolved": "https://registry.npmmirror.com/flv.js/-/flv.js-1.6.2.tgz",
"integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==",
"dependencies": {
"es6-promise": "^4.2.8",
"webworkify-webpack": "^2.1.5"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
@ -2412,6 +2570,11 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regexparam": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-2.0.2.tgz",
@ -2751,6 +2914,11 @@
"typescript": ">=5.0.0"
}
},
"node_modules/webworkify-webpack": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz",
"integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw=="
},
"node_modules/wetlandguard-admin": {
"resolved": "",
"link": true

View File

@ -9,10 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"@dataview/datav-vue3": "^0.0.0-test.1672506674342",
"@element-plus/icons-vue": "^2.3.1",
"@kjgl77/datav-vue3": "^1.7.4",
"axios": "^1.7.9",
"datav-vue3": "^1.0.0",
"echarts": "^5.6.0",
"element-plus": "^2.9.3",
"flv.js": "^1.6.2",
"json-server": "^1.0.0-beta.3",
"pinia": "^2.3.1",
"vue": "^3.5.13",

10
src/api/chart/index.js Normal file
View File

@ -0,0 +1,10 @@
import request from '@/utils/request'
/**
* 生成图表数据
* @param {Object} params - 图表参数
* @returns {Promise} 返回图表配置数据
*/
export function generateChartData(params = {}) {
return request.post('/api/ai/chart/generate', params)
}

51
src/api/device/index.js Normal file
View File

@ -0,0 +1,51 @@
import request from '@/utils/request'
/**
* 获取设备列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页条数
* @param {string} [params.device_type] - 设备类型10000-摄像头10001-无人机
* @returns {Promise} 返回设备列表数据
*/
export function getDeviceList(params = {}) {
console.log('请求参数:', params)
return request.get('/api/device/list', {
params: {
page: params.page || 1,
page_size: params.page_size || 10,
device_type: params.device_type
}
}).then(res => {
console.log('API返回数据:', res)
return res
})
}
/**
* 添加设备
* @param {Object} data - 设备数据
* @returns {Promise} 返回添加结果
*/
export function addDevice(data) {
return request.post('/api/device/add', data)
}
/**
* 更新设备
* @param {string|number} id - 设备ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateDevice(id, data) {
return request.put(`/api/device/${id}`, data)
}
/**
* 删除设备
* @param {string|number} id - 设备ID
* @returns {Promise} 返回删除结果
*/
export function deleteDevice(id) {
return request.delete(`/api/device/${id}`)
}

Binary file not shown.

View File

@ -138,6 +138,16 @@ const handleLogout = () => {
<el-menu-item index="/patrol/events">安防事件</el-menu-item>
</el-sub-menu>
<el-sub-menu index="AIPatrol">
<template #title>
<el-icon><component :is="icons.Location" /></el-icon>
<span>AI巡护</span>
</template>
<el-menu-item index="/AIPatrol/camera">摄像头管理</el-menu-item>
<el-menu-item index="/AIPatrol/sensor">传感器管理</el-menu-item>
<el-menu-item index="/AIPatrol/drone">无人机管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="report">
<template #title>
<el-icon><component :is="icons.Document" /></el-icon>

View File

@ -18,6 +18,15 @@ const router = createRouter({
name: 'Login',
component: () => import('../views/login/index.vue')
},
{
path: '/screen',
name: 'Screen',
component: () => import('@/views/dashboard/screen/index.vue'),
meta: {
title: '数据大屏',
requiresAuth: true
}
},
{
path: '/',
component: AdminLayout,
@ -29,11 +38,6 @@ const router = createRouter({
name: 'Dashboard',
component: () => import('../views/dashboard/index.vue')
},
{
path: 'screen',
name: 'DataScreen',
component: () => import('../views/dashboard/screen/index.vue')
},
{
path: 'system/users',
name: 'UserManagement',
@ -146,6 +150,21 @@ const router = createRouter({
name: 'KnowledgeManagement',
component: () => import('../views/activity/knowledge/index.vue')
},
{
path: 'AIPatrol/sensor',
name: 'SensorManagement',
component: () => import('../views/AIPatrol/sensor/index.vue')
},
{
path: 'AIPatrol/camera',
name: 'CameraManagement',
component: () => import('../views/AIPatrol/Camera/index.vue')
},
{
path: 'AIPatrol/drone',
name: 'DroneManagement',
component: () => import('../views/AIPatrol/drone/index.vue')
},
{
path: 'feedback/suggestions',
name: 'FeedbackSuggestions',

View File

@ -42,10 +42,16 @@ service.interceptors.response.use(
}
const res = response.data;
console.log('响应数据:', res)
// 如果响应成功
if (res.success) {
return res;
if (res.code === 200 || res.success) {
return {
code: 200,
success: true,
message: res.message || 'success',
data: res.data || res
};
}
// 处理特定错误码
@ -67,18 +73,7 @@ service.interceptors.response.use(
return Promise.reject(new Error(res.message || '请求失败'));
},
(error) => {
console.error('响应错误详情:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
headers: error.response?.headers,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
params: error.config?.params
}
});
console.error('响应错误详情:', error);
// 处理网络错误
if (!error.response) {

50
src/utils/videoPlayer.js Normal file
View File

@ -0,0 +1,50 @@
import flvjs from 'flv.js'
// 视频流服务器配置
const VIDEO_SERVER = {
url: 'http://localhost:8799/live',
type: 'flv'
}
/**
* 生成设备视频流URL
* @param {string} deviceCode - 设备编号
* @returns {string} 视频流URL
*/
export function generateStreamUrl(deviceCode) {
return `${VIDEO_SERVER.url}/${deviceCode}.flv`
}
/**
* 创建FLV播放器
* @param {HTMLVideoElement} videoElement - video元素
* @param {string} deviceCode - 设备编号
* @returns {flvjs.Player|null} FLV播放器实例
*/
export function createFlvPlayer(videoElement, deviceCode) {
if (flvjs.isSupported()) {
const flvPlayer = flvjs.createPlayer({
type: VIDEO_SERVER.type,
url: generateStreamUrl(deviceCode),
isLive: true,
})
flvPlayer.attachMediaElement(videoElement)
flvPlayer.load()
flvPlayer.play()
return flvPlayer
}
return null
}
/**
* 销毁FLV播放器
* @param {flvjs.Player} player - FLV播放器实例
*/
export function destroyFlvPlayer(player) {
if (player) {
player.pause()
player.unload()
player.detachMediaElement()
player.destroy()
}
}

View File

@ -0,0 +1,577 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, VideoCamera } from '@element-plus/icons-vue'
import { getDeviceList, addDevice, updateDevice, deleteDevice } from '@/api/device'
import { createFlvPlayer, destroyFlvPlayer } from '@/utils/videoPlayer'
//
const cameraList = ref([])
const loading = ref(false)
const mainVideoRef = ref(null)
const mainFlvPlayer = ref(null)
const videoRefs = ref([])
const flvPlayers = ref([])
//
const pagination = ref({
page: 1,
page_size: 10,
total: 0,
total_pages: 0
})
//
const statusOptions = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '故障', value: 'fault' }
]
//
const dialogVisible = ref(false)
const dialogTitle = ref('新增摄像头')
const formRef = ref(null)
const formData = ref({
device_name: '',
device_code: '',
device_type: '10000',
model: '',
manufacturer: '',
specifications: '',
install_location: '',
install_time: null,
maintenance_cycle: null,
status: {
code: 0,
text: '离线'
}
})
//
const rules = {
device_name: [
{ required: true, message: '请输入摄像头名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
device_code: [
{ required: true, message: '请输入摄像头编号', trigger: 'blur' }
],
install_location: [
{ required: true, message: '请输入安装位置', trigger: 'blur' }
]
}
//
const currentCameraIndex = ref(0)
//
const initVideoPlayers = () => {
//
if (mainFlvPlayer.value) {
destroyFlvPlayer(mainFlvPlayer.value)
}
flvPlayers.value.forEach(player => destroyFlvPlayer(player))
flvPlayers.value = []
//
if (mainVideoRef.value && cameraList.value[currentCameraIndex.value]?.status === 'online') {
const deviceCode = cameraList.value[currentCameraIndex.value].code
mainFlvPlayer.value = createFlvPlayer(mainVideoRef.value, deviceCode)
}
//
videoRefs.value.forEach((videoRef, index) => {
if (videoRef && cameraList.value[index]?.status === 'online') {
const deviceCode = cameraList.value[index].code
const player = createFlvPlayer(videoRef, deviceCode)
flvPlayers.value.push(player)
}
})
}
//
watch(() => cameraList.value, () => {
nextTick(() => {
initVideoPlayers()
})
}, { deep: true })
//
const getList = async () => {
loading.value = true
try {
const res = await getDeviceList({
page: pagination.page,
page_size: pagination.page_size,
device_type: '10000' //
})
if (res.code === 200) {
cameraList.value = res.data.list.map(camera => ({
id: camera.id,
name: camera.device_name,
code: camera.device_code,
status: camera.status.code === 1 ? 'online' : 'offline',
status_text: camera.status.text,
model: camera.model || '',
manufacturer: camera.manufacturer || '',
specifications: camera.specifications || '',
location: camera.install_location || '',
install_time: camera.install_time,
maintenance_cycle: camera.maintenance_cycle,
battery_level: camera.battery_level || 0,
signal_strength: camera.signal_strength || 0
}))
pagination.value.total = res.data.total
} else {
ElMessage.error(res.message || '获取摄像头列表失败')
}
} catch (error) {
console.error('获取摄像头列表出错:', error)
ElMessage.error('获取摄像头列表失败')
} finally {
loading.value = false
}
}
//
const handleSizeChange = (val) => {
pagination.value.page_size = val
pagination.value.page = 1
getList()
}
const handleCurrentChange = (val) => {
pagination.value.page = val
getList()
}
//
const handleAdd = () => {
dialogTitle.value = '新增摄像头'
formData.value = {
device_name: '',
device_code: '',
device_type: '10000',
model: '',
manufacturer: '',
specifications: '',
install_location: '',
install_time: null,
maintenance_cycle: null,
status: {
code: 0,
text: '离线'
}
}
dialogVisible.value = true
}
//
const handleEdit = (camera) => {
dialogTitle.value = '编辑摄像头'
formData.value = {
id: camera.id,
device_name: camera.name,
device_code: camera.code,
device_type: '10000',
model: camera.model,
manufacturer: camera.manufacturer,
specifications: camera.specifications,
install_location: camera.location,
install_time: camera.install_time,
maintenance_cycle: camera.maintenance_cycle,
status: {
code: camera.status === 'online' ? 1 : 0,
text: camera.status_text
}
}
dialogVisible.value = true
}
//
const handleDelete = (camera) => {
ElMessageBox.confirm('确认删除该摄像头吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDevice(camera.id)
if (res.success) {
ElMessage.success('删除成功')
getList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除摄像头错误:', error)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const submitData = {
...formData.value,
device_type: '10000'
}
const res = await (formData.value.id
? updateDevice(formData.value.id, submitData)
: addDevice(submitData))
if (res.success) {
ElMessage.success(formData.value.id ? '更新成功' : '添加成功')
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || (formData.value.id ? '更新失败' : '添加失败'))
}
} catch (error) {
console.error('提交表单错误:', error)
ElMessage.error(formData.value.id ? '更新失败' : '添加失败')
}
}
onMounted(() => {
getList()
})
onUnmounted(() => {
//
if (mainFlvPlayer.value) {
destroyFlvPlayer(mainFlvPlayer.value)
}
flvPlayers.value.forEach(player => destroyFlvPlayer(player))
})
</script>
<template>
<div class="camera-management">
<!-- 主视频区域 -->
<div class="main-video-container">
<video
v-if="cameraList[currentCameraIndex]?.status === 'online'"
ref="mainVideoRef"
class="main-video"
muted
></video>
<div v-else class="offline-mask">
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>摄像头离线</span>
</div>
<div class="video-info">
<h2>{{ cameraList[currentCameraIndex]?.name }}</h2>
<p>{{ cameraList[currentCameraIndex]?.description }}</p>
</div>
</div>
<!-- 底部摄像头列表 -->
<div class="camera-list-container">
<div class="list-header">
<h3>监控点位列表</h3>
<el-button type="primary" :icon="Plus" @click="handleAdd">新增摄像头</el-button>
</div>
<div class="camera-list">
<div
v-for="(camera, index) in cameraList"
:key="camera.id"
class="camera-item"
:class="{
'offline': camera.status !== 'online',
'active': currentCameraIndex === index
}"
@click="switchMainVideo(index)"
>
<div class="video-container">
<video
v-if="camera.status === 'online'"
:ref="el => { if (el) videoRefs[index] = el }"
class="video-player"
muted
></video>
<div v-else class="offline-mask">
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>摄像头离线</span>
</div>
</div>
<div class="camera-info">
<div class="info-header">
<h4>{{ camera.name }}</h4>
<el-tag
size="small"
:type="camera.status === 'online' ? 'success' : camera.status === 'offline' ? 'danger' : 'warning'"
>
{{ camera.status === 'online' ? '在线' : camera.status === 'offline' ? '离线' : '故障' }}
</el-tag>
</div>
<p class="location">{{ camera.location }}</p>
<div class="camera-actions">
<el-button type="primary" :icon="Edit" link @click="handleEdit(camera)">编辑</el-button>
<el-button type="danger" :icon="Delete" link @click="handleDelete(camera)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
destroy-on-close
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="摄像头名称" prop="device_name">
<el-input
v-model="formData.device_name"
placeholder="请输入摄像头名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="摄像头编号" prop="device_code">
<el-input
v-model="formData.device_code"
placeholder="请输入摄像头编号"
maxlength="20"
show-word-limit
/>
</el-form-item>
<el-form-item label="安装位置" prop="install_location">
<el-input
v-model="formData.install_location"
placeholder="请输入安装位置"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.camera-management {
height: 100%;
display: flex;
flex-direction: column;
.main-video-container {
position: relative;
height: calc(100vh - 200px);
background: #000;
overflow: hidden;
.main-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.offline-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
.offline-icon {
font-size: 48px;
color: #909399;
margin-bottom: 16px;
}
span {
font-size: 16px;
color: #909399;
}
}
.video-info {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: #fff;
h2 {
font-size: 20px;
margin: 0 0 8px;
}
p {
font-size: 14px;
margin: 0;
opacity: 0.8;
}
}
}
.camera-list-container {
height: 200px;
padding: 12px;
box-sizing: border-box;
background: #f5f7fa;
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
color: #333;
}
}
.camera-list {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 0 16px;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
.camera-item {
flex: 0 0 280px;
background: #fff;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
cursor: pointer;
&.active {
border: 2px solid #409EFF;
transform: translateY(-5px);
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
&.offline {
.video-container {
background: #f5f5f5;
}
}
.video-container {
height: 160px;
background: #000;
position: relative;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.offline-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
.offline-icon {
font-size: 32px;
margin-bottom: 8px;
}
span {
font-size: 14px;
}
}
}
.camera-info {
padding: 12px;
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h4 {
color: #333;
margin: 0;
font-size: 14px;
font-weight: normal;
}
}
.location {
color: #666;
font-size: 12px;
margin: 4px 0;
}
}
.camera-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
:deep(.el-button) {
color: #606266;
&:hover {
color: #409EFF;
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,470 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { Plus, VideoCamera } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getDeviceList } from '@/api/device'
import { createFlvPlayer, destroyFlvPlayer } from '@/utils/videoPlayer'
//
const droneList = ref([])
const loading = ref(false)
const mainVideoRef = ref(null)
const mainFlvPlayer = ref(null)
const videoRefs = ref([])
const flvPlayers = ref([])
//
const initVideoPlayers = () => {
//
if (mainFlvPlayer.value) {
destroyFlvPlayer(mainFlvPlayer.value)
}
flvPlayers.value.forEach(player => destroyFlvPlayer(player))
flvPlayers.value = []
//
if (mainVideoRef.value && droneList.value[currentDroneIndex.value]?.status === 'online') {
const deviceCode = droneList.value[currentDroneIndex.value].code
mainFlvPlayer.value = createFlvPlayer(mainVideoRef.value, deviceCode)
}
//
videoRefs.value.forEach((videoRef, index) => {
if (videoRef && droneList.value[index]?.status === 'online') {
const deviceCode = droneList.value[index].code
const player = createFlvPlayer(videoRef, deviceCode)
flvPlayers.value.push(player)
}
})
}
//
watch(() => droneList.value, () => {
nextTick(() => {
initVideoPlayers()
})
}, { deep: true })
//
const pagination = ref({
page: 1,
page_size: 10,
total: 0
})
//
const currentDroneIndex = ref(0)
//
const getList = async () => {
loading.value = true
try {
const res = await getDeviceList({
page: pagination.value.page,
page_size: pagination.value.page_size,
device_type: '10001' //
})
if (res.code === 200) {
droneList.value = res.data.list.map(drone => ({
id: drone.id,
name: drone.device_name,
code: drone.device_code,
status: drone.status.code === 1 ? 'online' : 'offline',
status_text: drone.status.text,
battery_level: drone.battery_level || 0,
signal_strength: drone.signal_strength || 0
}))
pagination.value.total = res.data.total
} else {
ElMessage.error(res.message || '获取无人机列表失败')
}
} catch (error) {
console.error('获取无人机列表错误:', error)
ElMessage.error('获取无人机列表失败')
} finally {
loading.value = false
}
}
//
const switchMainVideo = (index) => {
currentDroneIndex.value = index
nextTick(() => {
initVideoPlayers()
})
}
//
const handleSizeChange = (val) => {
pagination.value.page_size = val
getList()
}
const handleCurrentChange = (val) => {
pagination.value.page = val
getList()
}
onMounted(() => {
getList()
})
onUnmounted(() => {
if (mainFlvPlayer.value) {
destroyFlvPlayer(mainFlvPlayer.value)
}
flvPlayers.value.forEach(player => destroyFlvPlayer(player))
})
</script>
<template>
<div class="drone-management">
<!-- 左侧主视频区域 -->
<div class="main-video-container">
<video
v-if="droneList[currentDroneIndex]?.status === 'online'"
ref="mainVideoRef"
class="main-video"
muted
></video>
<div v-else class="offline-mask">
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>无人机离线</span>
</div>
<div class="video-info">
<h2>{{ droneList[currentDroneIndex]?.name }}</h2>
<div class="drone-status">
<div class="status-item">
<span class="label">电量:</span>
<span class="value">{{ droneList[currentDroneIndex]?.battery_level }}%</span>
</div>
<div class="status-item">
<span class="label">信号:</span>
<span class="value">{{ droneList[currentDroneIndex]?.signal_strength }}%</span>
</div>
</div>
</div>
</div>
<!-- 右侧视频列表 -->
<div class="video-list-container">
<div class="list-header">
<h3>无人机列表</h3>
<el-button type="primary" :icon="Plus">添加无人机</el-button>
</div>
<div class="video-list">
<div
v-for="(drone, index) in droneList"
:key="drone.id"
class="video-item"
:class="{
'offline': drone.status !== 'online',
'active': currentDroneIndex === index
}"
@click="switchMainVideo(index)"
>
<div class="video-container">
<video
v-if="drone.status === 'online'"
:ref="el => { if (el) videoRefs[index] = el }"
class="video-player"
muted
></video>
<div v-else class="offline-mask">
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>无人机离线</span>
</div>
</div>
<div class="drone-info">
<div class="info-header">
<h4>{{ drone.name }}</h4>
<el-tag
size="small"
:type="drone.status === 'online' ? 'success' : 'danger'"
>
{{ drone.status === 'online' ? '在线' : '离线' }}
</el-tag>
</div>
<div class="status-info">
<span class="battery">电量: {{ drone.battery_level }}%</span>
<span class="signal">信号: {{ drone.signal_strength }}%</span>
</div>
</div>
</div>
</div>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.drone-management {
height: 100%;
display: flex;
gap: 16px;
padding: 16px;
background: #f5f7fa;
overflow: hidden;
box-sizing: border-box;
.main-video-container {
flex: 1;
height: calc(100% - 32px);
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
.main-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.offline-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
.offline-icon {
font-size: 48px;
color: #909399;
margin-bottom: 16px;
}
span {
font-size: 16px;
color: #909399;
}
}
.video-info {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: #fff;
h2 {
font-size: 20px;
margin: 0 0 12px;
}
.drone-status {
display: flex;
gap: 24px;
.status-item {
display: flex;
align-items: center;
gap: 8px;
.label {
opacity: 0.8;
}
.value {
font-weight: 500;
}
}
}
}
}
.video-list-container {
width: 360px;
height: calc(100% - 32px);
background: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
.list-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
color: #333;
}
}
.video-list {
flex: 1;
overflow-y: auto;
padding: 16px;
margin-right: 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
.video-item {
background: #fff;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
cursor: pointer;
border: 1px solid #eee;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.active {
border: 2px solid #409EFF;
transform: translateX(-2px);
}
&:hover {
transform: translateX(-2px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
&.offline {
.video-container {
background: #f5f5f5;
}
}
.video-container {
height: 200px;
background: #000;
position: relative;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.offline-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
.offline-icon {
font-size: 32px;
margin-bottom: 8px;
}
span {
font-size: 14px;
}
}
}
.drone-info {
padding: 12px;
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h4 {
color: #333;
margin: 0;
font-size: 14px;
font-weight: 500;
}
}
.status-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
.battery, .signal {
display: flex;
align-items: center;
gap: 4px;
}
}
}
}
}
.pagination-container {
padding: 16px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
background: #fff;
}
}
}
//
:deep(.drone-management *:not(.video-list)) {
&::-webkit-scrollbar {
width: 0 !important;
display: none !important;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
//
.video-list {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
</style>

View File

@ -0,0 +1,295 @@
<script setup>
import { ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
//
const sensorList = ref([
{
id: 1,
name: '水质传感器01',
type: 'water',
status: 'online',
data: {
temperature: '25.6°C',
ph: '7.2',
oxygen: '6.8mg/L'
}
},
{
id: 2,
name: '空气传感器01',
type: 'air',
status: 'online',
data: {
temperature: '28.3°C',
humidity: '65%',
pm25: '35μg/m³'
}
},
{
id: 3,
name: '土壤传感器01',
type: 'soil',
status: 'offline',
data: {
moisture: '42%',
ph: '6.8',
nutrients: '中等'
}
},
{
id: 4,
name: '水质传感器02',
type: 'water',
status: 'online',
data: {
temperature: '26.1°C',
ph: '7.4',
oxygen: '7.1mg/L'
}
},
{
id: 5,
name: '空气传感器02',
type: 'air',
status: 'error',
data: {
temperature: '27.8°C',
humidity: '58%',
pm25: '42μg/m³'
}
}
])
//
const getStatusStyle = (status) => {
return {
online: {
color: '#67C23A',
text: '在线'
},
offline: {
color: '#909399',
text: '离线'
},
error: {
color: '#F56C6C',
text: '异常'
}
}[status]
}
//
const getTypeInfo = (type) => {
return {
water: {
icon: 'WaterMeter',
text: '水质传感器',
color: '#409EFF'
},
air: {
icon: 'Sunny',
text: '空气传感器',
color: '#67C23A'
},
soil: {
icon: 'Plant',
text: '土壤传感器',
color: '#E6A23C'
}
}[type]
}
</script>
<template>
<div class="sensor-management">
<div class="page-header">
<div class="header-title">传感器管理</div>
<el-button type="primary" :icon="Plus">添加传感器</el-button>
</div>
<div class="sensor-container">
<div
v-for="sensor in sensorList"
:key="sensor.id"
class="sensor-card"
:class="{ 'offline': sensor.status === 'offline' }"
>
<div class="card-header">
<div class="sensor-info">
<el-icon :class="sensor.type">
<component :is="getTypeInfo(sensor.type).icon" />
</el-icon>
<span class="sensor-name">{{ sensor.name }}</span>
<el-tag
size="small"
:type="sensor.status === 'online' ? 'success' : sensor.status === 'error' ? 'danger' : 'info'"
>
{{ getStatusStyle(sensor.status).text }}
</el-tag>
</div>
<div class="sensor-actions">
<el-button type="primary" link>编辑</el-button>
<el-button type="danger" link>删除</el-button>
</div>
</div>
<div class="card-content">
<template v-if="sensor.type === 'water'">
<div class="data-item">
<span class="label">温度</span>
<span class="value">{{ sensor.data.temperature }}</span>
</div>
<div class="data-item">
<span class="label">pH值</span>
<span class="value">{{ sensor.data.ph }}</span>
</div>
<div class="data-item">
<span class="label">溶解氧</span>
<span class="value">{{ sensor.data.oxygen }}</span>
</div>
</template>
<template v-else-if="sensor.type === 'air'">
<div class="data-item">
<span class="label">温度</span>
<span class="value">{{ sensor.data.temperature }}</span>
</div>
<div class="data-item">
<span class="label">湿度</span>
<span class="value">{{ sensor.data.humidity }}</span>
</div>
<div class="data-item">
<span class="label">PM2.5</span>
<span class="value">{{ sensor.data.pm25 }}</span>
</div>
</template>
<template v-else>
<div class="data-item">
<span class="label">湿度</span>
<span class="value">{{ sensor.data.moisture }}</span>
</div>
<div class="data-item">
<span class="label">pH值</span>
<span class="value">{{ sensor.data.ph }}</span>
</div>
<div class="data-item">
<span class="label">养分</span>
<span class="value">{{ sensor.data.nutrients }}</span>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.sensor-management {
padding: 16px;
height: 100%;
box-sizing: border-box;
background: #f5f7fa;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.header-title {
font-size: 20px;
font-weight: 600;
color: #333;
}
}
.sensor-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
.sensor-card {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.offline {
opacity: 0.8;
background: #f5f7fa;
}
.card-header {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
.sensor-info {
display: flex;
align-items: center;
gap: 8px;
.el-icon {
font-size: 20px;
&.water {
color: #409EFF;
}
&.air {
color: #67C23A;
}
&.soil {
color: #E6A23C;
}
}
.sensor-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
}
}
.card-content {
padding: 16px;
.data-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.label {
color: #666;
font-size: 14px;
}
.value {
color: #333;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="data-chart">
<div class="chart-title">数据统计</div>
<div class="chart-content">
<!-- 临时占位内容 -->
<div class="placeholder">数据图表内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.data-chart {
height: 100%;
padding: 16px;
.chart-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.chart-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,555 @@
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { getDeviceList } from '@/api/device'
import { createFlvPlayer, destroyFlvPlayer } from '@/utils/videoPlayer'
import * as echarts from 'echarts'
import { generateChartData } from '@/api/chart'
import { ElNotification, ElMessage } from 'element-plus'
import {
Monitor,
Connection,
Loading
} from '@element-plus/icons-vue'
//
const SOUND_CONNECT = new Audio('/src/assets/harmonyos-sound/notification_accomplished_08.wav')
const SOUND_DISCONNECT = new Audio('/src/assets/harmonyos-sound/notification_wrong_04.wav')
//
const playSound = (status) => {
const sound = status === 'online' ? SOUND_CONNECT : SOUND_DISCONNECT
sound.currentTime = 0 //
sound.play().catch(error => {
console.error('音效播放失败:', error)
})
}
//
const drone = ref(null)
const loading = ref(false)
const videoRef = ref(null)
const flvPlayer = ref(null)
const chartRef = ref(null)
const videoLoading = ref(true) //
let chart = null
let ws = null
//
const getChartData = async () => {
try {
const res = await generateChartData({
message: "ph柱状图"
})
console.log('完整的响应数据:', res)
if (res.success) {
console.log('图表配置数据:', JSON.stringify(res.data.echart_options, null, 2))
initChart(res.data)
} else {
console.error('获取图表数据失败:', res.message)
initChart(getDefaultChartData())
}
} catch (error) {
console.error('获取图表数据失败:', error)
initChart(getDefaultChartData())
}
}
//
const initChart = (data) => {
if (!chartRef.value) return
if (chart) {
chart.dispose()
}
chart = echarts.init(chartRef.value)
// 使
const option = {
backgroundColor: 'transparent',
title: {
...data.echart_options.title,
textStyle: {
color: '#fff'
}
},
tooltip: data.echart_options.tooltip,
legend: {
...data.echart_options.legend,
textStyle: {
color: '#fff'
}
},
xAxis: {
type: 'category',
data: data.echart_options.series[0].xAxis.data,
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
yAxis: {
type: 'value',
name: 'pH值',
max: 8,
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
series: [{
name: '酸性指数',
type: 'line',
smooth: true,
data: data.echart_options.series[0].xAxis.data.map(() => (Math.random() * 2 + 6).toFixed(1)), //
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.7)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
])
},
itemStyle: {
color: '#409EFF'
}
}]
}
console.log('最终的图表配置:', JSON.stringify(option, null, 2))
chart.setOption(option)
}
//
const getDefaultChartData = () => {
return {
echart_options: {
title: {
text: "pH值变化趋势"
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
}
},
legend: {
data: ["pH值"]
},
xAxis: {
type: "category",
data: []
},
yAxis: [
{
type: "value",
name: "pH值",
max: 8
}
],
series: [
{
name: "pH值",
type: "line",
smooth: true,
areaStyle: {},
data: []
}
]
}
}
}
//
const showDeviceStatusNotification = (status) => {
//
playSound(status)
ElNotification({
title: status === 'online' ? '设备已接入' : '设备已接出',
message: status === 'online' ?
`设备 ${drone.value?.name || '无人机'} 已接入,正在获取视频流...` :
`设备 ${drone.value?.name || '无人机'} 已接出,切换为数据图表显示`,
type: status === 'online' ? 'success' : 'warning',
duration: 3000
})
}
// WebSocket
const initWebSocket = () => {
ws = new WebSocket('ws://192.168.1.158:6894')
ws.onopen = () => {
console.log('WebSocket连接成功')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'device_status') {
//
if (drone.value) {
const newStatus = data.status === 1 ? 'online' : 'offline'
//
if (drone.value.status !== newStatus) {
drone.value.status = newStatus
showDeviceStatusNotification(newStatus)
//
if (newStatus === 'online') {
initVideoPlayer()
} else {
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
getChartData()
}
}
}
}
} catch (error) {
console.error('WebSocket消息处理错误:', error)
}
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
}
ws.onclose = () => {
console.log('WebSocket连接关闭')
//
setTimeout(initWebSocket, 5000)
}
}
//
const getDroneInfo = async () => {
loading.value = true
try {
const res = await getDeviceList({
page: 1,
page_size: 10,
device_type: '10001'
})
console.log('获取设备列表响应:', res)
if (res.code === 200) {
// 线
const onlineDrone = res.data.list.find(item => item.status.code === 1)
if (onlineDrone) {
drone.value = {
id: onlineDrone.id,
name: onlineDrone.device_name,
code: onlineDrone.device_code,
status: 'online',
battery: onlineDrone.battery_level || 0,
signal: onlineDrone.signal_strength || 0
}
//
nextTick(() => {
initVideoPlayer()
})
} else {
drone.value = null
// 线
getChartData()
}
} else {
ElMessage.error(res.message || '获取无人机列表失败')
getChartData()
}
} catch (error) {
console.error('获取无人机列表错误:', error)
ElMessage.error('获取无人机列表失败')
getChartData()
} finally {
loading.value = false
}
}
//
const initVideoPlayer = () => {
console.log('初始化视频播放器, 设备信息:', drone.value)
videoLoading.value = true // loading
//
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
flvPlayer.value = null
}
//
if (videoRef.value && drone.value?.status === 'online') {
console.log('创建FLV播放器, 设备编码:', drone.value.code)
flvPlayer.value = createFlvPlayer(videoRef.value, drone.value.code)
if (flvPlayer.value) {
flvPlayer.value.on('loading', () => {
console.log('视频加载中...')
videoLoading.value = true
})
flvPlayer.value.on('error', (errorType, errorDetail) => {
console.error('视频播放错误:', errorType, errorDetail)
ElMessage.error('视频加载失败,请检查网络连接')
videoLoading.value = false
})
//
videoRef.value.addEventListener('playing', () => {
console.log('视频开始播放')
videoLoading.value = false
})
videoRef.value.addEventListener('waiting', () => {
console.log('视频缓冲中')
videoLoading.value = true
})
} else {
console.error('播放器创建失败')
ElMessage.error('视频播放器初始化失败')
videoLoading.value = false
}
}
}
//
let refreshTimer = null
const startRefreshTimer = () => {
refreshTimer = setInterval(() => {
getDroneInfo()
if (drone.value?.status !== 'online') {
getChartData()
}
}, 30000) // 30
}
//
const handleResize = () => {
if (chart) {
chart.resize()
}
}
onMounted(() => {
// WebSocket
initWebSocket()
//
getDroneInfo()
//
startRefreshTimer()
//
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
// WebSocket
if (ws) {
ws.close()
ws = null
}
if (refreshTimer) {
clearInterval(refreshTimer)
}
if (flvPlayer.value) {
destroyFlvPlayer(flvPlayer.value)
}
if (chart) {
chart.dispose()
chart = null
}
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="video-monitor">
<div class="monitor-header">
<div class="title">{{ drone?.status === 'online' ? '无人机实时监控' : 'pH值变化趋势' }}</div>
<div class="status" v-if="drone">
<el-tag :type="drone?.status === 'online' ? 'success' : 'danger'" size="small">
{{ drone?.status === 'online' ? '在线' : '离线' }}
</el-tag>
</div>
</div>
<div class="content-container">
<!-- 在线显示视频 -->
<div v-if="drone?.status === 'online'" class="video-wrapper">
<video
ref="videoRef"
class="video-player"
muted
></video>
<!-- 添加 loading 遮罩 -->
<div v-if="videoLoading" class="video-loading">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">视频加载中...</span>
</div>
<div class="video-info">
<div class="info-row">
<span class="device-name">{{ drone.name }}</span>
<div class="device-metrics">
<el-tooltip content="电池电量" placement="top">
<div class="metric">
<el-icon><Monitor /></el-icon>
{{ drone.battery }}%
</div>
</el-tooltip>
<el-tooltip content="信号强度" placement="top">
<div class="metric">
<el-icon><Connection /></el-icon>
{{ drone.signal }}%
</div>
</el-tooltip>
</div>
</div>
</div>
</div>
<!-- 离线显示图表 -->
<div v-else class="chart-wrapper" ref="chartRef"></div>
</div>
</div>
</template>
<style lang="scss" scoped>
.video-monitor {
height: 100%;
padding: 20px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
display: flex;
flex-direction: column;
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
font-size: 20px;
font-weight: 500;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
}
.content-container {
flex: 1;
position: relative;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.4);
.video-wrapper {
width: 100%;
height: 100%;
position: relative;
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
}
// loading
.video-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
z-index: 2;
.loading-icon {
font-size: 32px;
color: #fff;
animation: rotate 1s linear infinite;
}
.loading-text {
color: #fff;
font-size: 14px;
}
}
.video-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
.device-name {
font-size: 16px;
font-weight: 500;
color: #fff;
}
.device-metrics {
display: flex;
gap: 20px;
.metric {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
font-size: 14px;
.el-icon {
font-size: 16px;
}
}
}
}
}
}
.chart-wrapper {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
}
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,42 @@
<script setup>
import VideoMonitor from './VideoMonitor.vue'
import DataChart from './DataChart.vue'
</script>
<template>
<div class="center-panel-container">
<div class="video-section">
<VideoMonitor />
</div>
<div class="chart-section">
<DataChart />
</div>
</div>
</template>
<style lang="scss" scoped>
.center-panel-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.video-section {
flex: 3;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(4px);
overflow: hidden;
}
.chart-section {
flex: 2;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(4px);
overflow: hidden;
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="bottom-card">
<div class="card-title">预警信息</div>
<div class="card-content">
<!-- 临时占位内容 -->
<div class="placeholder">预警信息内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.bottom-card {
height: 100%;
padding: 16px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.card-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="middle-card">
<div class="card-title">监测数据</div>
<div class="card-content">
<!-- 临时占位内容 -->
<div class="placeholder">监测数据内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.middle-card {
height: 100%;
padding: 16px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.card-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,347 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
//
const ecoData = ref({
airQuality: {
name: '空气质量指数',
value: [65, 68, 75, 82, 86, 82, 78],
level: '良好',
color: '#67C23A'
},
waterQuality: {
name: '水质指数',
value: [92, 90, 94, 95, 93, 96, 95],
level: '优',
color: '#409EFF'
},
biodiversity: {
name: '生物多样性',
value: [72, 75, 73, 78, 76, 74, 78],
level: '中等',
color: '#E6A23C'
},
vegetation: {
name: '植被覆盖率',
value: [82, 80, 85, 83, 85, 87, 85],
level: '良好',
color: '#67C23A'
}
})
const timeData = ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00']
let chart = null
const initChart = () => {
const chartDom = document.getElementById('ecoChart')
if (!chartDom) return
chart = echarts.init(chartDom)
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['空气质量指数', '水质指数', '生物多样性', '植被覆盖率'],
textStyle: {
color: '#fff'
},
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '25%',
containLabel: true
},
xAxis: {
type: 'category',
data: timeData,
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
series: [
{
name: '空气质量指数',
type: 'bar',
data: ecoData.value.airQuality.value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#67C23A' },
{ offset: 1, color: 'rgba(103, 194, 58, 0.3)' }
])
},
barWidth: '15%'
},
{
name: '水质指数',
type: 'line',
data: ecoData.value.waterQuality.value,
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
color: '#409EFF'
},
itemStyle: {
color: '#409EFF',
borderWidth: 2,
borderColor: '#fff'
}
},
{
name: '生物多样性',
type: 'bar',
data: ecoData.value.biodiversity.value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#E6A23C' },
{ offset: 1, color: 'rgba(230, 162, 60, 0.3)' }
])
},
barWidth: '15%'
},
{
name: '植被覆盖率',
type: 'line',
data: ecoData.value.vegetation.value,
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
color: '#67C23A'
},
itemStyle: {
color: '#67C23A',
borderWidth: 2,
borderColor: '#fff'
}
}
]
}
chart.setOption(option)
}
//
const updateData = () => {
if (!chart) return
//
const now = new Date()
timeData.shift()
timeData.push(now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0'))
//
Object.keys(ecoData.value).forEach(key => {
const values = ecoData.value[key].value
values.shift()
values.push(Math.floor(70 + Math.random() * 30))
})
chart.setOption({
xAxis: {
data: timeData
},
series: [
{
data: ecoData.value.airQuality.value
},
{
data: ecoData.value.waterQuality.value
},
{
data: ecoData.value.biodiversity.value
},
{
data: ecoData.value.vegetation.value
}
]
})
}
let timer = null
onMounted(() => {
initChart()
//
timer = setInterval(updateData, 3000)
//
window.addEventListener('resize', () => {
chart && chart.resize()
})
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
if (chart) {
chart.dispose()
chart = null
}
window.removeEventListener('resize', () => {
chart && chart.resize()
})
})
</script>
<template>
<div class="top-card">
<div class="card-header">
<div class="title">生态指标</div>
<div class="update-time">实时监测中</div>
</div>
<div id="ecoChart" class="chart-container"></div>
<div class="indicators-list">
<div
v-for="(item, key) in ecoData"
:key="key"
class="indicator-item"
>
<span class="name">{{ item.name }}</span>
<span class="value" :style="{ color: item.color }">{{ item.value[item.value.length - 1] }}</span>
<span class="level" :style="{ background: item.color }">{{ item.level }}</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.top-card {
height: 100%;
padding: 16px;
box-sizing: border-box;
background: rgba(6, 30, 93, 0.5);
border-radius: 4px;
.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;
}
}
}
.chart-container {
height: calc(100% - 140px);
width: 100%;
}
.indicators-list {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.indicator-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 10px;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 70%;
background: rgba(255, 255, 255, 0.1);
}
.name {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.value {
font-size: 20px;
font-weight: bold;
font-family: 'Monaco', monospace;
}
.level {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
color: #fff;
opacity: 0.8;
}
}
}
}
@keyframes blink {
0% { opacity: 0.2; }
50% { opacity: 1; }
100% { opacity: 0.2; }
}
</style>

View File

@ -0,0 +1,37 @@
<script setup>
import TopCard from './TopCard.vue'
import MiddleCard from './MiddleCard.vue'
import BottomCard from './BottomCard.vue'
</script>
<template>
<div class="left-panel-container">
<div class="panel-item">
<TopCard />
</div>
<div class="panel-item">
<MiddleCard />
</div>
<div class="panel-item">
<BottomCard />
</div>
</div>
</template>
<style lang="scss" scoped>
.left-panel-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.panel-item {
flex: 1;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(4px);
overflow: hidden;
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="bottom-card">
<div class="card-title">告警信息</div>
<div class="card-content">
<!-- 临时占位内容 -->
<div class="placeholder">告警信息内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.bottom-card {
height: 100%;
padding: 16px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.card-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="middle-card">
<div class="card-title">重点指标</div>
<div class="card-content">
<!-- 临时占位内容 -->
<div class="placeholder">重点指标内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.middle-card {
height: 100%;
padding: 16px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.card-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
//
</script>
<template>
<div class="top-card">
<div class="card-title">天气信息</div>
<div class="card-content">
<!-- 临时占位内容 -->
<div class="placeholder">天气信息内容区域</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.top-card {
height: 100%;
padding: 16px;
.card-title {
font-size: 16px;
font-weight: 500;
color: #fff;
margin-bottom: 16px;
}
.card-content {
height: calc(100% - 32px);
display: flex;
align-items: center;
justify-content: center;
.placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
</style>

View File

@ -0,0 +1,37 @@
<script setup>
import TopCard from './TopCard.vue'
import MiddleCard from './MiddleCard.vue'
import BottomCard from './BottomCard.vue'
</script>
<template>
<div class="right-panel-container">
<div class="panel-item">
<TopCard />
</div>
<div class="panel-item">
<MiddleCard />
</div>
<div class="panel-item">
<BottomCard />
</div>
</div>
</template>
<style lang="scss" scoped>
.right-panel-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.panel-item {
flex: 1;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(4px);
overflow: hidden;
}
}
</style>

View File

@ -1,11 +1,30 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import * as echarts from "echarts";
import LeftPanel from './components/LeftPanel/index.vue'
import CenterPanel from './components/CenterPanel/index.vue'
import RightPanel from './components/RightPanel/index.vue'
import { BorderBox1, Decoration1 } from 'datav-vue3'
const router = useRouter();
const isFullscreen = ref(false);
const isPopupWindow = ref(!!window.opener);
const currentTime = ref('');
//
const updateTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
currentTime.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
//
let timer = null;
const handleFullScreen = () => {
if (!document.fullscreenElement) {
@ -30,235 +49,82 @@ const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
};
//
const initCarbonChart = () => {
const chartDom = document.getElementById("carbonChart");
if (!chartDom) return;
const chart = echarts.init(chartDom);
const option = {
title: {
text: "湿地碳汇趋势",
textStyle: {
color: "#fff",
},
},
tooltip: {
trigger: "axis",
},
legend: {
data: ["碳储量", "碳吸收量"],
textStyle: {
color: "#fff",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
boundaryGap: false,
data: ["1月", "2月", "3月", "4月", "5月", "6月"],
axisLabel: {
color: "#fff",
},
},
yAxis: {
type: "value",
name: "单位: 吨",
axisLabel: {
color: "#fff",
},
},
series: [
{
name: "碳储量",
type: "line",
data: [320, 332, 341, 354, 360, 368],
smooth: true,
lineStyle: {
width: 3,
},
},
{
name: "碳吸收量",
type: "line",
data: [12, 13, 11, 14, 9, 13],
smooth: true,
lineStyle: {
width: 3,
},
},
],
};
chart.setOption(option);
};
//
const initEcoChart = () => {
const chartDom = document.getElementById("ecoChart");
if (!chartDom) return;
const chart = echarts.init(chartDom);
const option = {
title: {
text: "生态系统健康指标",
textStyle: {
color: "#fff",
},
},
radar: {
indicator: [
{ name: "物种多样性", max: 100 },
{ name: "水质", max: 100 },
{ name: "土壤质量", max: 100 },
{ name: "植被覆盖", max: 100 },
{ name: "空气质量", max: 100 },
],
splitArea: {
show: true,
areaStyle: {
color: ["rgba(255,255,255,0.1)"],
},
},
axisLine: {
lineStyle: {
color: "rgba(255,255,255,0.2)",
},
},
name: {
textStyle: {
color: "#fff",
},
},
},
series: [
{
type: "radar",
data: [
{
value: [85, 90, 88, 95, 89],
name: "当前状态",
areaStyle: {
opacity: 0.3,
},
},
],
},
],
};
chart.setOption(option);
};
//
onMounted(() => {
document.addEventListener("fullscreenchange", handleFullscreenChange);
if (isPopupWindow.value) {
handleFullScreen();
}
initCarbonChart();
initEcoChart();
//
updateTime();
timer = setInterval(updateTime, 1000);
});
onUnmounted(() => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
//
if (timer) {
clearInterval(timer);
}
});
</script>
<template>
<div class="screen-container">
<div class="screen-header">
<div class="header-title">智慧湿地生态监测大屏</div>
<div class="header-right">
<el-button type="default" @click="handleClose" class="mr-10">
{{ isPopupWindow ? "关闭窗口" : "返回" }}
</el-button>
<el-button type="primary" @click="handleFullScreen">
{{ isFullscreen ? "退出全屏" : "全屏显示" }}
</el-button>
<BorderBox1>
<div class="screen-header">
<Decoration1 class="header-decoration" />
<div class="header-title">智慧湿地生态监测大屏</div>
<div class="header-time">{{ currentTime }}</div>
<div class="header-right">
<el-button type="default" @click="handleClose" class="mr-10">
{{ isPopupWindow ? "关闭窗口" : "返回" }}
</el-button>
<el-button type="primary" @click="handleFullScreen">
{{ isFullscreen ? "退出全屏" : "全屏显示" }}
</el-button>
</div>
</div>
</div>
<div class="screen-content">
<el-row :gutter="20">
<!-- 左侧统计卡片 -->
<el-col :span="6">
<div class="data-card">
<div class="card-title">年度碳汇总量</div>
<div class="card-value">368<span class="unit"></span></div>
<div class="card-compare">同比增长 <span class="up">8.2%</span></div>
<div class="screen-content">
<div class="panel-container">
<div class="left-panel">
<BorderBox1>
<LeftPanel />
</BorderBox1>
</div>
<div class="data-card">
<div class="card-title">湿地面积</div>
<div class="card-value">1280<span class="unit">公顷</span></div>
<div class="card-compare">保护率 <span class="normal">95%</span></div>
</div>
<div class="data-card">
<div class="card-title">物种数量</div>
<div class="card-value">1860<span class="unit"></span></div>
<div class="card-compare">新增 <span class="up">12</span></div>
</div>
</el-col>
<!-- 中间图表 -->
<el-col :span="12">
<div class="chart-card">
<div id="carbonChart" style="height: 360px"></div>
<div class="center-panel">
<BorderBox1>
<CenterPanel />
</BorderBox1>
</div>
<div class="info-card">
<div class="info-item">
<div class="info-title">实时监测</div>
<div class="info-content">
<el-tag type="success">水质正常</el-tag>
<el-tag type="success">空气质量优</el-tag>
<el-tag type="warning">土壤湿度偏低</el-tag>
</div>
</div>
</div>
</el-col>
<!-- 右侧图表 -->
<el-col :span="6">
<div class="chart-card">
<div id="ecoChart" style="height: 360px"></div>
<div class="right-panel">
<BorderBox1>
<RightPanel />
</BorderBox1>
</div>
<div class="data-card">
<div class="card-title">生态预警</div>
<div class="warning-list">
<div class="warning-item">
<el-tag type="warning">B区水位偏低</el-tag>
<span class="time">10分钟前</span>
</div>
<div class="warning-item">
<el-tag type="success">已解决: A区水质异常</el-tag>
<span class="time">2小时前</span>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</div>
</BorderBox1>
</div>
</template>
<style lang="scss" scoped>
.mr-10 {
margin-right: 10px;
}
.screen-container {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #0f1527;
background: #0f1325;
color: #fff;
z-index: 999;
overflow: hidden;
:deep(.dv-border-box-1) {
width: 100%;
height: 100%;
padding: 15px;
box-sizing: border-box;
}
.screen-header {
position: relative;
@ -266,11 +132,36 @@ onUnmounted(() => {
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.2);
background: rgba(6, 30, 93, 0.5);
border-bottom: 2px solid rgba(1, 153, 209, .5);
margin-bottom: 16px;
.header-decoration {
position: absolute;
width: 100%;
height: 60px;
top: 0;
}
.header-title {
font-size: 24px;
position: relative;
font-size: 32px;
font-weight: bold;
background: linear-gradient(to bottom, #ffffff, #3fa7dd);
-webkit-background-clip: text;
color: transparent;
letter-spacing: 4px;
text-shadow: 0 0 10px rgba(1, 153, 209, .5);
}
.header-time {
position: absolute;
right: 240px;
top: 65%;
font-family: 'Monaco', monospace;
font-size: 20px;
color: #3fa7dd;
text-shadow: 0 0 10px rgba(1, 153, 209, .5);
}
.header-right {
@ -278,90 +169,53 @@ onUnmounted(() => {
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
:deep(.el-button) {
background: rgba(1, 153, 209, 0.2);
border-color: rgba(1, 153, 209, 0.5);
color: #fff;
&:hover {
background: rgba(1, 153, 209, 0.3);
border-color: rgba(1, 153, 209, 0.7);
}
}
}
}
.screen-content {
padding: 20px;
height: calc(100vh - 60px);
overflow: hidden;
height: calc(100vh - 96px);
box-sizing: border-box;
.data-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
.panel-container {
height: 100%;
display: flex;
gap: 16px;
.card-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 10px;
}
.left-panel,
.right-panel {
width: 25%;
height: 100%;
.card-value {
font-size: 32px;
font-weight: bold;
color: #fff;
margin-bottom: 10px;
.unit {
font-size: 14px;
margin-left: 4px;
opacity: 0.7;
:deep(.dv-border-box-1) {
padding: 12px;
}
}
.card-compare {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
.center-panel {
flex: 1;
height: 100%;
.up {
color: #67c23a;
}
.normal {
color: #409eff;
}
}
}
.chart-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.info-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 20px;
.info-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 10px;
}
.info-content {
display: flex;
gap: 10px;
}
}
.warning-list {
.warning-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.time {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
:deep(.dv-border-box-1) {
padding: 12px;
}
}
}
}
}
.mr-10 {
margin-right: 10px;
}
</style>