【类 型】:feat

【原  因】:需要根据用户选择展示飞行时长、飞行距离、消耗电量的不同统计图表
【过  程】:
- 新增 radioClass 状态切换逻辑
- 根据选择动态处理 flyDataList,计算不同类型字段(如时长、距离、电量)
- 动态生成 ECharts dataset.source 和 series 配置
- 统一处理时间粒度(日 / 月 / 年)
- 保持图表响应式和联动饼图功能

【影  响】:
用户可通过单选按钮自由切换三种维度图表
This commit is contained in:
air 2025-06-13 20:07:33 +08:00
parent 2a1c1a24ed
commit 647eb16956
8 changed files with 548 additions and 8 deletions

59
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": {
"axios": "^1.9.0",
"core-js": "^3.6.5",
"echarts": "^5.6.0",
"element-ui": "^2.15.14",
"lodash": "^4.17.21",
"mapbox-gl": "^2.15.0",
@ -6291,6 +6292,20 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -17487,6 +17502,19 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
"dev": true
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
},
"dependencies": {
@ -22265,6 +22293,22 @@
"safer-buffer": "^2.1.0"
}
},
"echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"requires": {
"tslib": "2.3.0",
"zrender": "5.6.1"
},
"dependencies": {
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -31208,6 +31252,21 @@
"dev": true
}
}
},
"zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"requires": {
"tslib": "2.3.0"
},
"dependencies": {
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
}
}
}

View File

@ -9,18 +9,19 @@
"lint": "eslint --ext .js,.vue src"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^2.6.11",
"vuex": "^3.4.0",
"axios": "^1.9.0",
"core-js": "^3.6.5",
"echarts": "^5.6.0",
"element-ui": "^2.15.14",
"lodash": "^4.17.21",
"mapbox-gl": "^2.15.0",
"mqtt": "^2.18.9",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"vue": "^2.6.11",
"vue-router": "^3.6.5",
"vue-template-compiler": "^2.7.16"
"vue-template-compiler": "^2.7.16",
"vuex": "^3.4.0"
},
"devDependencies": {
"@mapbox/mapbox-gl-draw": "^1.5.0",
@ -45,4 +46,4 @@
"main": ".eslintrc.js",
"author": "",
"license": "ISC"
}
}

View File

@ -0,0 +1,131 @@
<template>
<el-date-picker
v-model="internalValue"
type="daterange"
align="right"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:picker-options="pickerOptions"
format="yyyy-MM-dd"
@change="onChange"
/>
</template>
<script>
export default {
name: 'DateRangePicker',
props: {
value: {
type: Array,
default: () => []
}
},
data () {
return {
internalValue: [], //
pickerOptions: {
shortcuts: [
{
text: '今天',
onClick (picker) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
}
},
{
text: '本月',
onClick (picker) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
}
},
{
text: '本年',
onClick (picker) {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
}
},
{
text: '最近一周',
onClick (picker) {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
const start = new Date(end)
start.setDate(end.getDate() - 6)
start.setHours(0, 0, 0, 0)
picker.$emit('pick', [start, end])
}
},
{
text: '最近一个月',
onClick (picker) {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
const start = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate())
start.setHours(0, 0, 0, 0)
picker.$emit('pick', [start, end])
}
},
{
text: '最近三个月',
onClick (picker) {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setHours(23, 59, 59, 999)
const start = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate())
start.setHours(0, 0, 0, 0)
picker.$emit('pick', [start, end])
}
},
{
text: '上月',
onClick (picker) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth(), 0)
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
}
}
]
}
}
},
watch: {
value: {
immediate: true,
handler (newVal) {
if (Array.isArray(newVal) && newVal.length === 2) {
this.internalValue = [...newVal]
} else {
this.internalValue = []
}
}
}
},
methods: {
onChange (val) {
this.$emit('input', val)
this.$emit('change', val)
}
}
}
</script>

View File

@ -99,6 +99,17 @@ const routes = [
roles: ['admin', 'editor'],
tapName: 'plane'
}
},
{
path: '/register/flyData',
component: () => import('@/views/layout/components/main/register/flyData'),
meta: {
title: '飞行数据统计',
icon: 'el-icon-data-line',
roles: ['admin', 'editor'],
tapName: 'plane',
hidden: true
}
}
]
},

View File

@ -5,10 +5,11 @@ const state = {
isWideScreen: window.innerWidth < 480, // 屏幕宽度是否小于480
defaultLonLat: null, // 地图默认经纬度
defaultZoom: null, // 地图默认缩放
orderSerch: null, // 订单列表页搜索条件
/* 页面临时传参 */
toMessageIdArr: [] // 用户管理 发布公告页面 id临时传参
/* 页面参数 */
orderSerch: null, // 订单列表页搜索条件
toMessageIdArr: [], // 用户管理 发布公告页面 id临时传参
toFlyDataIdArr: [] // 飞机飞行数据 临时传参
}
const mutations = {
@ -56,8 +57,13 @@ const mutations = {
},
/* 临时传参 */
// 管理员管理 发布公告页面 传递id数组
setToMessageIdArr (state, idArr) {
state.toMessageIdArr = idArr
},
// 飞机飞行数据 传递id数组
setToFlyDataIdArr (state, idArr) {
state.toFlyDataIdArr = idArr
}
}

View File

@ -161,3 +161,20 @@ export async function pubMessage (tit, message, idArr, endTime) {
const res = await api.post('pubMessage', params, 'Admin') // 模块名根据你实际配置来
return res
}
/**
* @abstract 获取指定飞机组的飞行数据按时间范围
* @param {Array} idArr 飞机ID数组
* @param {Number} startTime 开始时间
* @param {Number} endTime 结束时间
* @returns 飞行数据列表
*/
export async function getFlyData (idArr, startTime, endTime) {
const params = new URLSearchParams()
params.append('idArr', idArr.join(',')) // 后端只收字符串,用逗号分隔
params.append('startTime', startTime)
params.append('endTime', endTime)
const res = await api.post('getFlyDataByIdArr', params, 'Plane')
return res
}

View File

@ -0,0 +1,303 @@
<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-group>
</div>
<div class="chart-area">
<div v-if="flyDataList.length" id="main" class="chart-container"></div>
<div v-else class="no-data-tip">暂无数据</div>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
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: '飞行时长'
}
},
components: {
DateRangePicker
},
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.toISOString().slice(0, 10),
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 (item.end_time - item.start_time) / 60
},
飞行距离: (item) => Number(item.distance || 0),
消耗电量: (item) => Number(item.power_used || 0)
}
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 () {
this.loadFlyData()
},
watch: {
flyDataList (newVal) {
if (!newVal.length && this.myChart) {
this.myChart.dispose()
this.myChart = null
} else if (newVal.length) {
this.$nextTick(() => {
this.initChart()
})
}
},
dateRange: {
handler () {
this.loadFlyData()
},
immediate: true
},
source (newVal) {
if (newVal.length > 1) {
this.initChart()
}
},
radioClass () {
this.initChart()
}
},
methods: {
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
console.log('飞行数据列表:', this.flyDataList)
} 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 === '飞行距离' ? '米' : 'mAh'
},
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

@ -6,6 +6,7 @@
<el-button type="danger" icon="el-icon-delete" @click="deleteAir(countSelIdArr($refs.myTable.selection))">删除
</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-group>
<!-- 用户select选项 -->
<el-button-group class="m-b-20">
@ -116,6 +117,17 @@ export default {
this.$message.error('只能选择一条记录')
}
},
/**
* @description: 跳转到飞机数据统计页面
*/
toFlyDataPage (selIdArr) {
if (selIdArr.length === 0) {
this.$message.error('请选择至少一架飞机')
} else {
this.$store.commit('app/setToFlyDataIdArr', selIdArr)//
this.$router.push('/register/flyData/')
}
},
/**
* @description: 删除飞机
*/