【类 型】:feat

【原  因】:1飞行数据删除功能 2导航栏取消添加 对拼等子页面显示 放到列表页显示  3.通过选飞机 直接到对拼页面
 【过  程】:
【影  响】:
This commit is contained in:
air 2025-06-23 14:07:30 +08:00
parent 241a418e59
commit 8d06b53183
10 changed files with 570 additions and 62 deletions

View File

@ -243,7 +243,7 @@ export default {
if (this.enableSaveToFile && typeof this.getData === 'function') {
this.map.addControl(new SaveToFileControl({
Data: this.getData,
filename: `export_${Date.now()}.json`,
filename: `飞行数据_${Date.now()}.json`,
title: '保存当前数据',
icon: '💾'
}), 'top-left')
@ -984,7 +984,7 @@ export default {
* @param {obj} lonLat {lon:lon,lat:lat} 经纬度
* @param {Number} zoom 地图放大率
*/
goto (lonLat, zoom = 18) {
goto (lonLat, zoom = 12) {
this.map.flyTo({
center: lonLat,
zoom: zoom,

View File

@ -87,7 +87,8 @@ const routes = [
title: '机型添加',
icon: 'el-icon-plus',
roles: ['admin', 'editor'],
tapName: 'plane'
tapName: 'plane',
hidden: true
}
},
{
@ -131,7 +132,8 @@ const routes = [
title: '飞机添加',
icon: 'el-icon-plus',
roles: ['admin', 'editor'],
tapName: 'plane'
tapName: 'plane',
hidden: true
}
},
{
@ -146,13 +148,14 @@ const routes = [
}
},
{
path: '/register/crosFrequency',
path: '/register/crosFrequency/:shop_id/:plane_id',
component: () => import('@/views/layout/components/main/register/crosFrequency'),
meta: {
title: '飞机对频',
icon: 'el-icon-link',
roles: ['admin', 'editor'],
tapName: 'plane'
tapName: 'plane',
hidden: true
}
},
{
@ -273,7 +276,8 @@ const routes = [
title: '站点添加',
icon: 'el-icon-plus',
roles: ['admin', 'editor'],
tapName: 'plane'
tapName: 'plane',
hidden: true
}
},
{
@ -383,7 +387,8 @@ const routes = [
title: '账户添加',
icon: 'iconfont icon-xinzengguanliyuan',
roles: ['admin', 'editor'],
tapName: 'admin'
tapName: 'admin',
hidden: true
}
},
{
@ -461,7 +466,8 @@ const routes = [
title: 'SPU添加',
icon: 'iconfont icon-huoquchanpin',
roles: ['admin', 'editor'],
tapName: 'admin'
tapName: 'admin',
hidden: true
}
},
{
@ -492,7 +498,8 @@ const routes = [
title: 'SKU添加',
icon: 'iconfont icon-sku1',
roles: ['admin', 'editor'],
tapName: 'admin'
tapName: 'admin',
hidden: true
}
},
{

View File

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

View File

@ -178,6 +178,23 @@ export async function getFlyData (idArr, startTime, endTime) {
const res = await api.post('getFlyDataByIdArr', params, 'Plane')
return res
}
/**
* @abstract 删除指定的飞行数据记录
* @param {Array} idArr 飞行数据记录ID数组
* @returns 删除结果
*/
export async function deleteFlyData (idArr) {
if (!Array.isArray(idArr) || idArr.length === 0) {
throw new Error('参数错误idArr 应为非空数组')
}
const params = new URLSearchParams()
params.append('idArr', idArr.join(',')) // 逗号分隔传给后端
const res = await api.post('deleteFlyDataByIdArr', params, 'Plane')
return res
}
/**
* @abstract 上传保存飞机飞行数据解锁至加锁 即飞行结束触发
* @param {Object} data 包含 id, start_time, end_time, gps_path, distance, power_used

View File

@ -160,9 +160,7 @@ export function formatPrice (value) {
}
/**
* 等待 Mapbox 地图的画布容器Canvas Container准备就绪
* 因为 Mapbox 初始化是异步的某些操作需要等待画布容器加载完成才能执行
*
* @description:等待 Mapbox 地图的画布容器Canvas Container准备就绪因为 Mapbox 初始化是异步的某些操作需要等待画布容器加载完成才能执行
* @param {Object} map - Mapbox GL JS 地图实例对象
* @param {number} maxRetry - 最大重试次数防止无限等待默认5次
* @returns {Promise<HTMLElement>} 返回一个 Promise成功时返回画布容器元素失败时抛出错误
@ -192,3 +190,32 @@ export function waitForMapCanvasReady (map, maxRetry = 5) {
check(maxRetry)
})
}
/**
* @description: 把对象保存为 JSON 文件并触发下载
* @param {*} data - 要保存的对象数据
* @param {string} filename - 下载的文件名默认为 data_时间
*/
export function saveObjectToFile (data, filename = `data_${Date.now()}.json`) {
if (!data || typeof data !== 'object') {
alert('无效的数据对象')
return
}
// 将对象格式化为 JSON 字符串
const content = JSON.stringify(data, null, 2)
// 创建 Blob 对象
const blob = new Blob([content], { type: 'application/json' })
// 创建一个临时下载链接
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
// 模拟点击以触发下载
link.click()
// 释放资源
URL.revokeObjectURL(link.href)
}

View File

@ -12,31 +12,27 @@
<el-main class="border p-20">
<el-form label-width="120px">
<el-form-item label="物理ID">
<el-tag :type="macAdd === null ? 'danger' : ''">{{ macAdd === null ? 'NoThing' : macAdd }}</el-tag>
<el-tag :type="macAdd === null ? 'danger' : ''">
{{ macAdd === null ? 'NoThing' : macAdd }}
</el-tag>
</el-form-item>
<el-form-item label="商铺分配">
<el-select v-model="shop_id" filterable placeholder="请选择" @blur="air_id = ''; disabled = true"
:disabled="macAdd === null ? true : false">
<el-option v-for="item in shopList" :key="item.id" :label="item.name" :value="item.shop_id">
<el-avatar class="vm" shape="square" :size="25"
:src="$store.state.settings.host + '/Data/UploadFiles/logo/' + item.logo">
</el-avatar>
<span class="rspan">{{ item.name }}</span>
</el-option>
</el-select>
<span>{{ getShopName(shop_id) }}</span>
</el-form-item>
<el-form-item label="飞机列表">
<el-radio-group v-if="airList.length != 0" v-model="air_id" @change="disabled = false;">
<el-radio class="m-b-10 m-l-0" v-for="item in airList" :key="item.id" :label="item.id" border>
{{ item.name }}
{{ item.macadd }}
</el-radio>
</el-radio-group>
<span v-else>No data</span>
<el-form-item label="飞机信息">
<span>{{ getAirName(plane_id) }}</span>
</el-form-item>
<el-form-item>
<el-button icon="iconfont icon-jiekou m-r-5" :type="macAdd === null ? 'info' : 'primary'"
@click="onSubmit" :disabled="macAdd === null ? 'disabled' : false">对频
<el-button
icon="iconfont icon-jiekou m-r-5"
:type="macAdd === null ? 'info' : 'primary'"
@click="onSubmit"
:disabled="macAdd === null"
>
对频
</el-button>
</el-form-item>
</el-form>
@ -44,7 +40,6 @@
</el-container>
</el-col>
</el-row>
</div>
</template>
@ -56,54 +51,63 @@ export default {
data () {
return {
shop_id: null,
air_id: null
plane_id: null
}
},
computed: {
//
shopList () {
return this.$store.state.shopList
},
//
airList () {
return this.$store.state.airList.filter((item) => item.shop_id === this.shop_id)
return this.$store.state.airList
},
//
macAdd () {
return this.$store.state.crosFrequency
}
},
created () {
this.shop_id = this.$route.params.shop_id
this.plane_id = this.$route.params.plane_id
console.log('shop_id:', this.shop_id, 'plane_id:', this.plane_id)
},
methods: {
/**
* @Description: 检查飞机发布过来的 mac地址 是否已经存在
* @Return: bool
* 获取商铺名称
*/
isMacAdd () {
let b = false
this.$store.state.airList.map((item) => {
if (item.macadd === this.macAdd) {
b = true
}
})
return b
getShopName (id) {
const shop = this.shopList.find(item => item.shop_id === id)
return shop ? shop.name : '未知商铺'
},
/**
* @description: 提交表单
* 获取飞机名称和 mac 地址
*/
getAirName (id) {
const air = this.airList.find(item => item.id === id)
return air ? `${air.name}` : '未知飞机'
},
/**
* 检查是否已有同名 mac 地址
*/
isMacAdd () {
return this.airList.some(item => item.macadd === this.macAdd)
},
/**
* 提交表单
*/
onSubmit () {
const params = { macAdd: this.macAdd, id: this.air_id }
const params = { macAdd: this.macAdd, id: this.plane_id }
if (this.isMacAdd()) {
this.$confirm('已经有同名mac地址,请谨慎操作?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
apiCrosFrequency(params)//
apiCrosFrequency(params)
}).catch(() => {
this.$message.info('取消对频')
})
} else {
apiCrosFrequency(params)//
apiCrosFrequency(params)
}
}
}
@ -114,6 +118,6 @@ export default {
.rspan {
float: right;
color: #8492a6;
font-size: 13px
font-size: 13px;
}
</style>

View File

@ -0,0 +1,364 @@
<template>
<div class="app-container">
<div class="fly-data-wrapper">
<div class="top-bar">
<DateRangePicker v-model="dateRange" class="m-r-20 m-b-20" />
<el-radio-group v-model="radioClass">
<el-radio-button label="作业架次"></el-radio-button>
<el-radio-button label="飞行时长"></el-radio-button>
<el-radio-button label="飞行距离"></el-radio-button>
<el-radio-button label="消耗电量"></el-radio-button>
<el-radio-button label="飞行轨迹"></el-radio-button>
</el-radio-group>
</div>
<div class="chart-area" v-if="flyDataList.length">
<div v-if="boxShow" id="main" class="chart-container"></div>
<map-box v-else ref="mapbox" @map-ready="onMapReady" :enableSaveToFile="true" :getData="() => flyDataList"/>
</div>
<div v-else class="no-data-tip">暂无数据</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import MapBox from '@/components/MapBox'
import { getFlyData } from '@/utils/api/table'
import DateRangePicker from '@/components/DateRangePicker'
export default {
name: 'FlyData',
data () {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date()
start.setDate(end.getDate() - 6)
start.setHours(0, 0, 0, 0)
return {
flyDataList: [],
selectedPlaneIdArr: this.$store.state.app.toFlyDataIdArr,
dateRange: [start, end],
radioClass: '飞行时长',
boxShow: true
}
},
components: {
DateRangePicker,
MapBox
},
computed: {
source () {
if (!this.flyDataList.length) return []
const start = this.dateRange[0]
const end = this.dateRange[1]
const rangeInMs = end - start
const oneDay = 24 * 60 * 60 * 1000
const oneYear = 365 * oneDay
let groupBy = 'day'
if (rangeInMs > oneYear) {
groupBy = 'year'
} else if (rangeInMs > 30 * oneDay) {
groupBy = 'month'
}
const formatMap = {
day: (d) => `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`,
month: (d) => `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}`,
year: (d) => `${d.getFullYear()}`
}
const formatFn = formatMap[groupBy]
const timeLabels = []
const cursor = new Date(start.getTime())
const endTime = end.getTime()
while (cursor.getTime() <= endTime) {
timeLabels.push(formatFn(new Date(cursor)))
if (groupBy === 'day') {
cursor.setDate(cursor.getDate() + 1)
} else if (groupBy === 'month') {
cursor.setMonth(cursor.getMonth() + 1)
} else if (groupBy === 'year') {
cursor.setFullYear(cursor.getFullYear() + 1)
}
}
const groupedData = {}
const keyMap = {
飞行时长: (item) => {
if (!item.start_time || !item.end_time) return 0
return Math.round((item.end_time - item.start_time) / 60) //
},
飞行距离: (item) => Number(item.distance || 0),
消耗电量: (item) => Number(item.power_used || 0),
作业架次: () => 1 // 1
}
if (this.radioClass === '飞行轨迹') return []//
this.flyDataList.forEach(item => {
const planeName = item.plane_name
const date = new Date(item.start_time * 1000)
const key = formatFn(date)
if (!groupedData[planeName]) groupedData[planeName] = {}
if (!groupedData[planeName][key]) groupedData[planeName][key] = 0
groupedData[planeName][key] += keyMap[this.radioClass](item)
})
const source = [['product', ...timeLabels]]
for (const planeName in groupedData) {
const row = [planeName]
for (const label of timeLabels) {
row.push(groupedData[planeName][label] || 0)
}
source.push(row)
}
return source
},
series () {
const lineSeries = this.source.slice(1).map(() => ({
type: 'line',
smooth: true,
seriesLayoutBy: 'row',
emphasis: { focus: 'series' }
}))
const firstColumn = this.source[0]?.[1] || ''
if (!firstColumn) return lineSeries
const pieSeries = {
type: 'pie',
id: 'pie',
radius: '30%',
center: ['50%', '25%'],
emphasis: { focus: 'self' },
label: {
formatter: `{b}: {@[${firstColumn}]} ({d}%)`
},
encode: {
itemName: 'product',
value: firstColumn,
tooltip: firstColumn
}
}
return [...lineSeries, pieSeries]
}
},
created () {
},
watch: {
flyDataList (newVal) {
//
if (!newVal.length && this.myChart) {
this.myChart.dispose()
this.myChart = null
} else if (newVal.length) {
this.$nextTick(() => {
this.initChart()
})
}
//
if (!this.boxShow && newVal.length) {
this.onMapReady()
}
},
boxShow (val) {
if (!val) {
this.onMapReady()
}
},
dateRange: {
handler () {
this.loadFlyData()
},
immediate: true
},
source (newVal) {
if (Array.isArray(newVal) && newVal.length > 1) {
this.initChart()
}
},
radioClass (val) {
if (val === '飞行轨迹') {
this.boxShow = false
} else {
this.boxShow = true
this.$nextTick(() => {
this.initChart()
})
}
}
},
methods: {
//
onMapReady () {
this.drawAllPathsOnMap()
},
//
drawAllPathsOnMap () {
if (!this.$refs.mapbox) return
//
this.$refs.mapbox.clearMapElements(['path'], ['path'])
//
this.flyDataList.forEach(item => {
if (!item.gps_path) return
try {
const pathArray = JSON.parse(item.gps_path)
if (Array.isArray(pathArray) && pathArray.length > 0) {
this.$refs.mapbox.createPathWithArray(pathArray)
}
} catch (e) {
console.warn('gps_path 解析失败', item.gps_path)
}
})
//
if (this.flyDataList.length > 0 && this.flyDataList[0].gps_path) {
try {
const firstPath = JSON.parse(this.flyDataList[0].gps_path)
if (Array.isArray(firstPath) && firstPath.length > 0) {
const [lon, lat] = firstPath[0]
this.$refs.mapbox.goto({ lon, lat })
}
} catch (e) {
//
}
}
},
//
async loadFlyData () {
if (this.selectedPlaneIdArr.length === 0) {
this.$router.push('/register/index')
return
}
if (!this.dateRange || this.dateRange.length !== 2) {
this.$message.warning('请选择日期范围')
return
}
const startTimestamp = Math.floor(this.dateRange[0].getTime() / 1000)
const endTimestamp = Math.floor(this.dateRange[1].getTime() / 1000)
const res = await getFlyData(this.selectedPlaneIdArr, startTimestamp, endTimestamp)
if (res.data.status === 1) {
this.flyDataList = res.data.dataList
} else {
this.$message.error(res.data.msg)
}
},
initChart () {
const chartDom = document.getElementById('main')
if (!chartDom) return
if (this.myChart) {
this.myChart.dispose()
}
this.myChart = echarts.init(chartDom)
const firstColumnIndex = 1
this.myChart.setOption({
legend: {},
tooltip: {
trigger: 'axis',
showContent: true
},
dataset: {
source: this.source
},
xAxis: { type: 'category' },
yAxis: {
gridIndex: 0,
name: this.radioClass === '飞行时长' ? '分钟' : this.radioClass === '飞行距离' ? '米' : this.radioClass === '消耗电量' ? '毫安' : (this.radioClass === '作业架次' ? '次' : '')
},
grid: { top: '55%' },
series: [
...this.source.slice(1).map(() => ({
type: 'line',
smooth: true,
seriesLayoutBy: 'row',
emphasis: { focus: 'series' }
})),
{
type: 'pie',
id: 'pie',
radius: '30%',
center: ['50%', '25%'],
emphasis: { focus: 'self' },
label: {
formatter: `{b}: {@[${firstColumnIndex}]} ({d}%)`
},
encode: {
itemName: 'product',
value: firstColumnIndex,
tooltip: firstColumnIndex
}
}
]
})
this.myChart.on('updateAxisPointer', (event) => {
const xAxisInfo = event.axesInfo[0]
if (xAxisInfo) {
const dimension = xAxisInfo.value + 1
this.myChart.setOption({
series: {
id: 'pie',
label: {
formatter: `{b}: {@[${dimension}]} ({d}%)`
},
encode: {
value: dimension,
tooltip: dimension
}
}
})
}
})
window.addEventListener('resize', () => {
this.myChart.resize()
})
}
}
}
</script>
<style lang="scss" scoped>
@import "@/styles/theme.scss";
.fly-data-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.top-bar {
flex: 0 0 auto;
padding-bottom: 10px;
}
.chart-area {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.chart-container {
flex: 1;
width: 100%;
}
</style>

View File

@ -4,13 +4,14 @@
<div class="top-bar">
<!-- 日期选择 -->
<span class="m-r-20 m-b-20" style="position: relative;">
<DateRangePicker v-model="dateRange" style="top:-10px"/>
<DateRangePicker v-model="dateRange" style="top:-10px" />
</span>
<!-- 组合按钮 -->
<el-button-group class="m-r-20 m-b-20">
<el-button type="primary" icon="el-icon-plus" @click="$router.replace('/site/add')">详情保存</el-button>
<el-button type="danger" icon="el-icon-delete"
@click="deleteSite(countSelIdArr($refs.myTable.selection))">删除</el-button>
<el-button type="primary" icon="iconfont icon-save-3-fill" @click="saveDataTOFile()">
<font class="m-l-5">详情保存</font>
</el-button>
<el-button type="danger" icon="el-icon-delete" @click="delFlyData()">删除</el-button>
</el-button-group>
<!-- 项目选择 -->
<el-radio-group v-model="radioClass" class="m-r-20 m-b-20">
@ -39,8 +40,9 @@
<script>
import * as echarts from 'echarts'
import MapBox from '@/components/MapBox'
import { getFlyData } from '@/utils/api/table'
import { getFlyData, deleteFlyData } from '@/utils/api/table'
import DateRangePicker from '@/components/DateRangePicker'
import { saveObjectToFile } from '@/utils'
export default {
name: 'FlyData',
@ -220,6 +222,41 @@ export default {
}
},
methods: {
//
async delFlyData () {
if (this.flyDataList.length === 0) {
this.$message.warning('暂无数据可删除')
return
}
const idArr = this.flyDataList.map(item => item.id)
this.$confirm('是否确认删除所有飞行数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deleteFlyData(idArr)
if (res.data.status === 1) {
this.$message.success(`飞行数据已删除: ${res.data.total}`)
this.flyDataList = [] //
} else {
this.$message.error(res.message || '删除失败')
}
this.flyDataList = []
}).catch(() => {
this.$message.info('已取消删除')
})
},
//
saveDataTOFile () {
if (this.flyDataList.length === 0) {
this.$message.warning('暂无数据可保存')
return
}
const fileName = `飞行数据_${new Date().toISOString().slice(0, 10)}.json`
saveObjectToFile(this.flyDataList, fileName)
},
//
onMapReady () {
this.drawAllHistoricalPaths()

View File

@ -46,15 +46,16 @@
{{ scope.row.apply_time | parseTime('{y}-{m}-{d}') }}
</template>
</el-table-column>
<el-table-column prop="controler" label="操作" width="380" min-width="380">
<el-table-column prop="controler" label="操作" width="460" min-width="460">
<template slot-scope="scope">
<el-button-group>
<el-button type="primary" class="iconfont icon-youxishoubing"
@click="$router.replace(`/planes/index/${scope.row.id}/${scope.row.name}`)"><span
class="m-l-5">操作</span></el-button>
class="m-l-5">控制</span></el-button>
<el-button type="warning" icon="el-icon-edit"
@click="$router.replace(`/register/edit/${scope.row.id}`)">编辑</el-button>
<el-button type="danger" icon="el-icon-delete" @click="deleteAir([scope.row.id])">删除</el-button>
<el-button type="success" icon="el-icon-link" @click="crosFrequencyToPage(form.shop_id,scope.row.id)">对频</el-button>
</el-button-group>
</template>
</el-table-column>
@ -149,6 +150,12 @@ export default {
*/
deleteAir (idArr) {
this.$store.dispatch('fetchDelAir', idArr)
},
/**
* @description: 跳转到对频页面
*/
crosFrequencyToPage (shopId, planeId) {
this.$router.push(`/register/crosFrequency/${shopId}/${planeId}`)
}
},
watch: {

View File

@ -0,0 +1,45 @@
<template>
<div>
<video
ref="video"
autoplay
controls
playsinline
muted
width="640"
height="360"
></video>
</div>
</template>
<script>
export default {
data () {
return {
url: 'http://82.156.122.87:80/rtc/v1/whep/?app=live&stream=083AF27BB2D0',
sdk: null
}
},
mounted () {
this.startPlay()
},
methods: {
async startPlay () {
if (this.sdk) {
this.sdk.close()
this.sdk = null
}
this.sdk = new window.SrsRtcWhipWhepAsync()
this.$refs.video.srcObject = this.sdk.stream
try {
const session = await this.sdk.play(this.url)
console.log('播放成功session:', session)
} catch (e) {
console.error('播放失败', e)
}
}
}
}
</script>