【类 型】:feat

【原  因】:完成集群飞机控制主页面  和  控制组件
【过  程】:
【影  响】:

# 类型 包含:
# feat:新功能(feature)
# fix:修补bug
# docs:文档(documentation)
# style: 格式(不影响代码运行的变动)
# refactor:重构(即不是新增功能,也不是修改bug的代码变动)
# test:增加测试
# chore:构建过程或辅助工具的变动
This commit is contained in:
szdot 2025-06-22 20:39:40 +08:00
parent 9f48971c72
commit e7506728af
10 changed files with 331 additions and 1171 deletions

View File

@ -207,12 +207,12 @@
</div> </div>
<div class="butIconBox gap10 flex"> <div class="butIconBox gap10 flex">
<el-button size="medium" type="primary" class="flex1 butIcon" <el-button size="medium" type="primary" class="flex1 butIcon"
@click="publishFun('{unlock:1}'); speakText('解锁飞机')"> @click="confirmation('确认对飞机解锁?请确保飞行环境安全。', '解锁操作', '{unlock:1}'); speakText('解锁飞机')">
<i class="iconfont icon-jiesuo f-s-24"></i> <i class="iconfont icon-jiesuo f-s-24"></i>
<div class="m-t-5">解锁</div> <div class="m-t-5">解锁</div>
</el-button> </el-button>
<el-button size="medium" type="primary" class="flex1 butIcon" <el-button size="medium" type="primary" class="flex1 butIcon"
@click="confirmation('飞机加锁,螺旋桨将停转,请谨慎操作!', '加锁操作', '{lock:1}'); speakText('加锁,请注意安全')"> @click="confirmation('飞机加锁,螺旋桨将停转,请谨慎操作!', '加锁操作', '{lock:1}'); speakText('加锁飞机,请注意安全')">
<i class=" iconfont icon-suoding f-s-24"></i> <i class=" iconfont icon-suoding f-s-24"></i>
<div class="m-t-5">加锁</div> <div class="m-t-5">加锁</div>
</el-button> </el-button>

File diff suppressed because it is too large Load Diff

View File

@ -300,6 +300,16 @@ const routes = [
tapName: 'plane' tapName: 'plane'
}, },
children: [ children: [
{
path: '/planes/swarm',
component: () => import('@/views/layout/components/main/planes/swarm'),
meta: {
title: '集群控制',
icon: 'iconfont icon-a-jiqunkongzhianniu_huaban1',
roles: ['admin', 'editor'],
tapName: 'plane'
}
},
{ {
path: '/planes/index/:id/:name', // 动态加载路由时加ID参数 path: '/planes/index/:id/:name', // 动态加载路由时加ID参数
component: () => import('@/views/layout/components/main/planes/index'), component: () => import('@/views/layout/components/main/planes/index'),

View File

@ -9,7 +9,8 @@ const state = {
/* 页面参数 */ /* 页面参数 */
orderSerch: null, // 订单列表页搜索条件 orderSerch: null, // 订单列表页搜索条件
toMessageIdArr: [], // 用户管理 发布公告页面 id临时传参 toMessageIdArr: [], // 用户管理 发布公告页面 id临时传参
toFlyDataIdArr: [] // 飞机飞行数据 临时传参 toFlyDataIdArr: [], // 飞机飞行数据 临时传参
swarmIdArr: []// 选中的 集群控制飞机ID组
} }
const mutations = { const mutations = {
@ -64,7 +65,12 @@ const mutations = {
// 飞机飞行数据 传递id数组 // 飞机飞行数据 传递id数组
setToFlyDataIdArr (state, idArr) { setToFlyDataIdArr (state, idArr) {
state.toFlyDataIdArr = idArr state.toFlyDataIdArr = idArr
},
// 设置 '选取的集群飞机'id组
setSwarmIdArr (state, idArr) {
state.swarmIdArr = idArr
} }
} }
const actions = { const actions = {

View File

@ -1 +1 @@
@import 'https://at.alicdn.com/t/c/font_3703467_793cqnnxv0f.css'; //iconfont阿里巴巴 @import 'https://at.alicdn.com/t/c/font_3703467_cqwk36imkj.css'; //iconfont阿里巴巴

View File

@ -73,7 +73,7 @@ export default {
async handler () { async handler () {
try { try {
// //
await waitForMapCanvasReady(this.map) await waitForMapCanvasReady(this.$refs.mapbox.map)
// //
this.onMapReady() this.onMapReady()
} catch (err) { } catch (err) {

View File

@ -23,15 +23,21 @@
<el-form-item label="经度" label-width="80px"> <el-form-item label="经度" label-width="80px">
<el-input v-model="guidedLonLat.lon" placeholder="请输经度" label="经度"></el-input> <el-input v-model="guidedLonLat.lon" placeholder="请输经度" label="经度"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="高度设置" label-width="80px"> <el-form-item label="高度" label-width="80px">
<el-input-number v-model="guidedAlt" label="高度设置"></el-input-number> <el-input-number v-model="guidedAlt" label="高度设置"></el-input-number>
<font class="m-l-5"></font> <font class="m-l-5"></font>
</el-form-item> </el-form-item>
</el-form> </el-form>
<span slot="footer" class="dialog-footer"> <span slot="footer" class="dialog-footer">
<el-button size="medium" @click="dialogVisible = false">关闭</el-button> <el-button size="medium" @click="dialogVisible = false">关闭</el-button>
<el-button size="medium" type="primary" <el-button size="medium" type="primary" @click="() => {
@click="publishFun(`{guidedMode:{lon:${guidedLonLat.lon},lat:${guidedLonLat.lat},alt:${guidedAlt}}`); isReserveGuidedMaker = true; dialogVisible = false">飞至</el-button> if (flyToSinglePlane(guidedLonLat.lon, guidedLonLat.lat, guidedAlt)) {
isReserveGuidedMaker = true;
dialogVisible = false;
}
}">
飞至
</el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
@ -128,6 +134,25 @@ export default {
} }
this.guidedAlt = height this.guidedAlt = height
}, },
// lon, lat, alt
flyToSinglePlane (lon, lat, alt) {
const lonNum = Number(lon)
const latNum = Number(lat)
const altNum = Number(alt)
if (
isNaN(lonNum) || isNaN(latNum) || isNaN(altNum) ||
lonNum < -180 || lonNum > 180 ||
latNum < -90 || latNum > 90
) {
this.$message.warning('请输入有效的经纬度(经度-180~180纬度-90~90和高度')
return false
}
const cmd = `{guidedMode:{lon:${lonNum.toFixed(7)},lat:${latNum.toFixed(7)},alt:${altNum.toFixed(1)}}}`
this.publishFun(cmd)
return true
},
// //
onMapReady () { onMapReady () {
this.mapReady = true// this.mapReady = true//
@ -190,7 +215,7 @@ export default {
async handler () { async handler () {
try { try {
// //
await waitForMapCanvasReady(this.map) await waitForMapCanvasReady(this.$refs.mapbox.map)
// //
this.onMapReady() this.onMapReady()
} catch (err) { } catch (err) {

View File

@ -1,13 +1,12 @@
<template> <template>
<div class="h-100"> <div class="h-100">
<!-- 地图组件 --> <!-- 地图组件 -->
<map-box ref="mapbox" :enableShowNofly="true" :enableGuided="true" :enableFollow="true" <map-box ref="mapbox" :enableShowNofly="true" :enableGuided="true" :enblueScale="!$store.state.app.isWideScreen"
:enblueScale="!$store.state.app.isWideScreen" @longPress="handleLongPress" @map-ready="onMapReady"> @longPress="handleLongPress" @map-ready="onMapReady">
<template #content> <template #content>
<div v-show="mapReady"> <div v-show="mapReady">
<BatteryStatus :plane="plane" /> <!-- <SwarmStatus :planes="planeList" /> -->
<PlaneStatus :plane="plane" /> <SwarmControllerTabs :planes="planeList" @mapXOffset="mapXOffset" @makeRoute="makeRoute" @clearRoute="clearRoute" />
<ControllerTabs :plane="plane" @mapXOffset="mapXOffset" @makeRoute="makeRoute" @clearRoute="clearRoute" />
</div> </div>
</template> </template>
</map-box> </map-box>
@ -17,21 +16,20 @@
<!-- 点飞设置弹出框 --> <!-- 点飞设置弹出框 -->
<template v-if="dialogItem == 'guidedBox'"> <template v-if="dialogItem == 'guidedBox'">
<el-form label-position="left"> <el-form label-position="left">
<el-form-item label="纬度" label-width="80px"> <el-form-item label="中心纬度" label-width="80px">
<el-input v-model="guidedLonLat.lat" placeholder="请输维度" label="纬度"></el-input> <el-input v-model="guidedLonLat.lat" placeholder="请输维度" label="纬度"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="经度" label-width="80px"> <el-form-item label="中心经度" label-width="80px">
<el-input v-model="guidedLonLat.lon" placeholder="请输经度" label="经度"></el-input> <el-input v-model="guidedLonLat.lon" placeholder="请输经度" label="经度"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="高度设置" label-width="80px"> <el-form-item label="中心高度" label-width="80px">
<el-input-number v-model="guidedAlt" label="高度设置"></el-input-number> <el-input-number v-model="guidedAlt" label="高度设置"></el-input-number>
<font class="m-l-5"></font> <font class="m-l-5"></font>
</el-form-item> </el-form-item>
</el-form> </el-form>
<span slot="footer" class="dialog-footer"> <span slot="footer" class="dialog-footer">
<el-button size="medium" @click="dialogVisible = false">关闭</el-button> <el-button size="medium" @click="dialogVisible = false">关闭</el-button>
<el-button size="medium" type="primary" <el-button size="medium" type="primary" @click="onSwarmFlyTo">飞至</el-button>
@click="publishFun(`{guidedMode:{lon:${guidedLonLat.lon},lat:${guidedLonLat.lat},alt:${guidedAlt}}`); isReserveGuidedMaker = true; dialogVisible = false">飞至</el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
@ -40,14 +38,12 @@
<script> <script>
import MapBox from '@/components/MapBox' import MapBox from '@/components/MapBox'
import ControllerTabs from '@/components/ControllerTabs' // import SwarmStatus from '@/components/SwarmStatus.vue'
import BatteryStatus from '@/components/BatteryStatus' import SwarmControllerTabs from '@/components/SwarmControllerTabs.vue'
import PlaneStatus from '@/components/PlaneStatus'
import mqtt from '@/utils/mqtt'
import { waitForMapCanvasReady } from '@/utils' import { waitForMapCanvasReady } from '@/utils'
export default { export default {
name: 'Planes', name: 'Swarm',
data () { data () {
return { return {
dialogTitle: '', // dialogTitle: '', //
@ -56,40 +52,20 @@ export default {
guidedLonLat: {}, // guidedLonLat: {}, //
guidedAlt: '', // guidedAlt: '', //
isReserveGuidedMaker: false, // isReserveGuidedMaker: false, //
planesId: this.$route.params.id, mapReady: false //
localCount: 0, //
mapReady: false//
} }
}, },
components: { components: {
MapBox, MapBox,
ControllerTabs, // SwarmStatus,
BatteryStatus, SwarmControllerTabs
PlaneStatus
}, },
computed: { computed: {
plane () { // s
if (this.$store.state.airList.length > 0) { planeList () {
return this.$store.state.airList.find(plane => plane.id === this.planesId) const allPlanes = this.$store.state.airList
} const idArr = this.$store.state.app.swarmIdArr
return null return allPlanes.filter(plane => idArr.includes(plane.id))
},
position () {
if (this.plane) {
if (this.plane.planeState.position.length > 0) {
return this.plane.planeState.position
} else {
return []
}
} else {
return []
}
},
noflyData () {
return this.$store.state.noflyData
},
ADSBList () {
return this.$store.state.ADSBList
}, },
/** /**
* @description: 侧边栏显隐 * @description: 侧边栏显隐
@ -98,6 +74,12 @@ export default {
return this.$store.state.app.isCollapse return this.$store.state.app.isCollapse
} }
}, },
created () {
//
if (this.$store.state.app.swarmIdArr.length < 2) {
this.$router.replace('/register/index')
}
},
methods: { methods: {
/** 弹出框 关闭事件回调 */ /** 弹出框 关闭事件回调 */
closeCallback () { closeCallback () {
@ -111,86 +93,136 @@ export default {
openCallback () { openCallback () {
}, },
// //
//
handleLongPress (lonLat) { handleLongPress (lonLat) {
this.isReserveGuidedMaker = false this.isReserveGuidedMaker = false
this.dialogTitle = '指点飞行' this.dialogTitle = '集群指点'
this.dialogVisible = true this.dialogVisible = true
this.dialogItem = 'guidedBox' this.dialogItem = 'guidedBox'
this.guidedLonLat = lonLat // this.guidedLonLat = lonLat //
// //
let height = 0 const validHeights = this.planeList.map(p => {
if (this.plane && this.plane.planeState && Array.isArray(this.plane.planeState.position)) { const pos = p?.planeState?.position
const posLen = this.plane.planeState.position.length const last = Array.isArray(pos) && pos.length > 0 ? pos[pos.length - 1] : null
if (posLen > 0 && Array.isArray(this.plane.planeState.position[posLen - 1])) { return Array.isArray(last) ? last[2] || 0 : 0
height = this.plane.planeState.position[posLen - 1][2] || 0 }).filter(h => typeof h === 'number')
}
const avgAlt = validHeights.length
? (validHeights.reduce((sum, h) => sum + h, 0) / validHeights.length).toFixed(1)
: 0
this.guidedAlt = Number(avgAlt)
},
//
onSwarmFlyTo () {
const targetLon = Number(this.guidedLonLat.lon)
const targetLat = Number(this.guidedLonLat.lat)
const targetAlt = Number(this.guidedAlt)
if (
isNaN(targetLon) || isNaN(targetLat) || isNaN(targetAlt) ||
targetLon < -180 || targetLon > 180 ||
targetLat < -90 || targetLat > 90
) {
this.$message.warning('请输入有效的经纬度(经度-180~180纬度-90~90和高度')
return
}
//
const currentCenter = this.getSwarmCenter()
if (!currentCenter) {
this.$message.error('无法获取飞行器中心位置,检查飞机是否都已定位')
return
}
//
const commands = this.planeList.map(p => {
const pos = p?.planeState?.position
const last = Array.isArray(pos) && pos.length > 0 ? pos[pos.length - 1] : null
if (!Array.isArray(last)) return null
const offsetLon = last[0] - currentCenter.lon
const offsetLat = last[1] - currentCenter.lat
const offsetAlt = last[2] - currentCenter.alt
const newLon = targetLon + offsetLon
const newLat = targetLat + offsetLat
const newAlt = targetAlt + offsetAlt
return {
id: p.id,
cmd: `{guidedMode:{lon:${newLon.toFixed(7)},lat:${newLat.toFixed(7)},alt:${newAlt.toFixed(1)}}}`
}
}).filter(Boolean)
//
commands.forEach(({ id, cmd }) => {
this.publishFun(cmd, id)
})
this.isReserveGuidedMaker = true
this.dialogVisible = false
},
//
getSwarmCenter () {
const positions = this.planeList.map(p => {
const pos = p?.planeState?.position
const last = Array.isArray(pos) && pos.length > 0 ? pos[pos.length - 1] : null
return Array.isArray(last) ? last : null
}).filter(Boolean)
if (!positions.length) return null
const sum = positions.reduce((acc, pos) => {
acc.lon += pos[0]
acc.lat += pos[1]
acc.alt += pos[2] || 0
return acc
}, { lon: 0, lat: 0, alt: 0 })
const count = positions.length
return {
lon: sum.lon / count,
lat: sum.lat / count,
alt: sum.alt / count
} }
this.guidedAlt = height
}, },
// //
onMapReady () { onMapReady () {
this.mapReady = true// this.mapReady = true //
this.makePlane(this.plane) this.makePlanes(this.planeList)
}, },
/** /**
* @description: 创建飞机图标 * @description: 创建飞机图标
*/ */
makePlane (plane) { makePlanes (planes) {
let planeDefaultLonLat
if (localStorage.getItem(plane.name)) { //
planeDefaultLonLat = JSON.parse(localStorage.getItem(plane.name))
plane.lon = planeDefaultLonLat.lon
plane.lat = planeDefaultLonLat.lat
} else {
// 10
plane.lon = 100 + Number((Math.random() * 0.01).toFixed(5))
plane.lat = 40 + Number((Math.random() * 0.01).toFixed(5))
}
this.$refs.mapbox.removePlanes()// this.$refs.mapbox.removePlanes()//
this.$refs.mapbox.makePlane(plane)// planes.forEach((plane, index) => { //
this.$refs.mapbox.goto({ lon: plane.lon, lat: plane.lat })// let planeDefaultLonLat
}, if (localStorage.getItem(plane.name) !== null) { //
/** planeDefaultLonLat = JSON.parse(localStorage.getItem(plane.name))
* @description: 创建航线 plane.lon = planeDefaultLonLat.lon
*/ plane.lat = planeDefaultLonLat.lat
makeRoute (routeData) { } else {
this.$refs.mapbox.makeRoute(routeData) // 10
}, plane.lon = 100 + Number((Math.random() * 0.01).toFixed(5))
/** plane.lat = 40 + Number((Math.random() * 0.01).toFixed(5))
* @description: 清楚航线 }
*/ this.$refs.mapbox.makePlane(plane, index)
clearRoute () { })
this.$refs.mapbox.clearRoute()
},
/**
* @description: 屏幕横移
* @param {*} val 正数向左移动 负数向右移动
* @param {*} y 正数向上移动 负数向下移动
*/
mapXOffset (x, y) {
this.$refs.mapbox.mapXOffset(x, y)
},
/**
* @description: 发布 mqtt 信息
* @param {*} jsonData {'item':val} // item: questAss setQuestState resetQuestState chan11 chan22 chan33 chan44 hookConteroller cameraController
*/
publishFun (jsonData) {
if (this.plane) {
mqtt.publishFun(`cmd/${this.plane.macadd}`, jsonData)
} else {
this.$message.warning('与飞机通信未接通,请稍后')
}
} }
}, },
mounted () {
},
watch: { watch: {
plane: { /**
* @description: 飞机列表更新时候 更新地图
*/
planeList: {
async handler () { async handler () {
try { try {
// //
await waitForMapCanvasReady(this.map) await waitForMapCanvasReady(this.$refs.mapbox.map)
// //
this.onMapReady() this.onMapReady()
} catch (err) { } catch (err) {
@ -200,47 +232,13 @@ export default {
immediate: true immediate: true
}, },
/** /**
* @description: 更新飞机位置 并画出轨迹 跟随飞机 * @description: 侧边栏显隐
*/ */
position: { isCollapse () {
handler (val) { return this.$store.state.app.isCollapse
const len = val.length
if (len > 2) {
const lon = val[len - 1][0]
const lat = val[len - 1][1]
this.localCount++//
if (this.localCount % 100 === 1) {
localStorage.setItem(this.plane.name, `{ "lon": ${lon}, "lat": ${lat} }`)
}
this.$refs.mapbox.setPlaneLonLat({ lon: lon, lat: lat }, 0, val)//
}
},
deep: true
},
ADSBList: {
handler (newList) {
if (this.$refs.mapbox && typeof this.$refs.mapbox.makeADSBPlanes === 'function') {
this.$refs.mapbox.makeADSBPlanes(newList)
}
},
immediate: true,
deep: true
},
/**
* @description: 侧边栏缩进有变化时 地图重新自适应
*/
isCollapse: {
handler (val) {
if (val) {
this.$nextTick(() => {
this.$refs.mapbox.handleResize()
})
}
}
} }
} }
} }
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@ -2,19 +2,21 @@
<div class="app-container"> <div class="app-container">
<!-- 组合按钮 --> <!-- 组合按钮 -->
<el-button-group class="m-r-20 m-b-20"> <el-button-group class="m-r-20 m-b-20">
<el-button type="primary" icon="el-icon-plus" @click="$router.replace('/register/add')">添加</el-button> <el-button type="primary" icon="el-icon-plus" @click="$router.replace('/register/add')">添加</el-button>
<el-button type="danger" icon="el-icon-delete" @click="deleteAir(countSelIdArr($refs.myTable.selection))">删除 <el-button type="danger" icon="el-icon-delete" @click="deleteAir(countSelIdArr($refs.myTable.selection))">删除
</el-button> </el-button>
<el-button type="warning" icon="el-icon-edit" @click="toEditPage()">编辑</el-button> <el-button type="warning" icon="el-icon-edit" @click="toEditPage()">编辑</el-button>
<el-button type="success" icon="el-icon-data-line" @click="toFlyDataPage(countSelIdArr($refs.myTable.selection))">飞行数据</el-button> <el-button type="warning" class="swarmButton" icon="iconfont icon-a-jiqunkongzhianniu_huaban1" @click="toSwarmPage()">集群控制</el-button>
<el-button type="success" icon="el-icon-data-line"
@click="toFlyDataPage(countSelIdArr($refs.myTable.selection))">飞行数据</el-button>
</el-button-group> </el-button-group>
<!-- 用户select选项 --> <!-- 用户select选项 -->
<el-button-group class="m-b-20"> <el-button-group class="m-b-20">
<SelectionShopId v-model="form.shop_id" :allSel="true" /> <SelectionShopId v-model="form.shop_id" :allSel="true" />
</el-button-group> </el-button-group>
<!-- 飞机表格 --> <!-- 飞机表格 -->
<el-table class="w-100" ref="myTable" <el-table class="w-100" ref="myTable" :data="airListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)"
:data="airListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)" border tooltip-effect="dark"> border tooltip-effect="dark">
<el-table-column align="center" type="selection" width="40"> <el-table-column align="center" type="selection" width="40">
</el-table-column> </el-table-column>
<el-table-column align="center" prop="id" label="id" width="50"> <el-table-column align="center" prop="id" label="id" width="50">
@ -117,6 +119,20 @@ export default {
this.$message.error('只能选择一条记录') this.$message.error('只能选择一条记录')
} }
}, },
/**
* @description: 跳转到编辑页面
*/
toSwarmPage () {
const selId = this.countSelIdArr(this.$refs.myTable.selection)
if (selId.length < 2) {
this.$message.error('请选择至少两架飞机')
return
}
// swarmIdArr
this.$store.commit('app/setSwarmIdArr', selId)
//
this.$router.push('/planes/swarm')
},
/** /**
* @description: 跳转到飞机数据统计页面 * @description: 跳转到飞机数据统计页面
*/ */
@ -155,6 +171,11 @@ export default {
} }
} }
.swarmButton {
background-color: #7C4DFF;
border-color: #7C4DFF
}
.no-wrap-btn-group { .no-wrap-btn-group {
white-space: nowrap; white-space: nowrap;
/* 禁止换行 */ /* 禁止换行 */

View File

@ -111,25 +111,34 @@ export default {
} }
}, },
/** /**
* @description: 动态加载路由 * @description: 动态加载无人机子路由并保留静态配置如集群控制
*/ * @param {Array} planes - 飞机对象数组每个对象包含 id name
*/
loadRoute (planes) { loadRoute (planes) {
const arr = new Array(0) // 1.
planes.map((item, index) => { const dynamicPlaneRoutes = planes.map((item) => ({
arr[index] = { path: '/planes/index/' + item.id + '/' + item.name, // ID
path: '/planes/index/' + item.id + '/' + item.name, component: () => import('@/views/layout/components/main/planes/index.vue'), //
component: () => import('@/views/layout/components/main/planes/index.vue'), meta: {
meta: { title: item.name, //
title: item.name, icon: 'iconfont icon-wurenji', //
icon: 'iconfont icon-wurenji', roles: ['admin', 'editor'], //
roles: ['admin', 'editor'], tapName: 'plane' //
tapName: 'plane' // activeMenu: '/planes/swarm' //
}
} }
}) }))
this.routes.map((element) => {
if (element.meta.title === '无人机') { // 2. path "/planes"
element.children = arr this.routes.forEach((element) => {
if (element.path === '/planes') {
// 3. children index
const staticChildren = element.children?.filter(child => !child.path.startsWith('/planes/index/')) || []
// 4. /planes/swarm +
element.children = [
...staticChildren,
...dynamicPlaneRoutes
]
} }
}) })
} }