Browse Source

【推进】

main
xiaosi 2 months ago
parent
commit
4d363ca54f
  1. 4
      .env.development
  2. 4
      .env.production
  3. 8
      components.d.ts
  4. 2301
      package-lock.json
  5. 2
      package.json
  6. 11
      src/app.config.js
  7. 12
      src/app.js
  8. BIN
      src/assets/droneDisImg.png
  9. BIN
      src/assets/droneImg.png
  10. BIN
      src/assets/errPoint.png
  11. BIN
      src/assets/fail.png
  12. 27
      src/assets/iconfont.css
  13. 199
      src/assets/iconfont.less
  14. BIN
      src/assets/iconfont.ttf
  15. BIN
      src/assets/iconfont.woff
  16. BIN
      src/assets/iconfont.woff2
  17. BIN
      src/assets/success.png
  18. 424
      src/components/FlightDashboard.vue
  19. 2
      src/components/RealTimeData.vue
  20. 324
      src/components/SupervisionData.vue
  21. 214
      src/components/SupervisionSideData.vue
  22. 10
      src/components/TabBar.vue
  23. 176
      src/components/TrackPlayback.vue
  24. 18
      src/config/fcSystemStatus.js
  25. 24
      src/config/flyModeMap.js
  26. 29
      src/config/gpsFixTypeMap.js
  27. 11
      src/config/stageMap.js
  28. 126
      src/config/tipTextMap.js
  29. 112
      src/config/urls.js
  30. 296
      src/core/deviceCruise.js
  31. 92
      src/core/helper/markHelper.js
  32. 281
      src/core/helper/pinHelper.js
  33. 273
      src/core/helper/sceneHelper.js
  34. 461
      src/core/useDeviceCruise.js
  35. 9
      src/pages/airfield/index.config.js
  36. 413
      src/pages/airfield/index.vue
  37. 93
      src/pages/airfieldMap/BottomSide.vue
  38. 222
      src/pages/airfieldMap/LeftSide.vue
  39. 124
      src/pages/airfieldMap/ResultModal.vue
  40. 257
      src/pages/airfieldMap/RightSide.vue
  41. 118
      src/pages/airfieldMap/geo.js
  42. 5
      src/pages/airfieldMap/index.config.js
  43. 444
      src/pages/airfieldMap/index.vue
  44. 224
      src/pages/airfieldMap/useConnector.js
  45. 320
      src/pages/airfieldMap/useDroneMarker.js
  46. 40
      src/pages/flight/index.vue
  47. 113
      src/pages/flightMap/BottomSide.vue
  48. 216
      src/pages/flightMap/LeftSide.vue
  49. 135
      src/pages/flightMap/RightSide.vue
  50. 142
      src/pages/flightMap/TopSide.vue
  51. 2
      src/pages/flightMap/index.config.js
  52. 317
      src/pages/flightMap/index.vue
  53. 189
      src/pages/flightMap/utils.js
  54. 26
      src/pages/home/index.vue
  55. 118
      src/pages/login/index.vue
  56. 3
      src/pages/own/index.vue
  57. 6
      src/pages/returnTripMap/utils.js
  58. 93
      src/pages/studentMap/BottomSide.vue
  59. 236
      src/pages/studentMap/LeftSide.vue
  60. 124
      src/pages/studentMap/ResultModal.vue
  61. 146
      src/pages/studentMap/RightSide.vue
  62. 118
      src/pages/studentMap/geo.js
  63. 5
      src/pages/studentMap/index.config.js
  64. 402
      src/pages/studentMap/index.vue
  65. 224
      src/pages/studentMap/useConnector.js
  66. 190
      src/pages/studentMap/useDroneMarker.js
  67. 188
      src/pages/supervision/index.vue
  68. 93
      src/pages/supervisionMap/BottomSide.vue
  69. 235
      src/pages/supervisionMap/LeftSide.vue
  70. 124
      src/pages/supervisionMap/ResultModal.vue
  71. 146
      src/pages/supervisionMap/RightSide.vue
  72. 118
      src/pages/supervisionMap/geo.js
  73. 2
      src/pages/supervisionMap/index.config.js
  74. 356
      src/pages/supervisionMap/index.vue
  75. 224
      src/pages/supervisionMap/useConnector.js
  76. 142
      src/pages/supervisionMap/useDroneMarker.js
  77. 4
      src/stores/index.js
  78. 143
      src/stores/modules/airfieldsStore.js
  79. 35
      src/stores/modules/authStore.js
  80. 26
      src/stores/modules/flightStore.js
  81. 99
      src/stores/modules/standardStore.js
  82. 320
      src/stores/modules/supervisionStore.js
  83. 118
      src/utils/geo.js
  84. 25
      src/utils/helpers.js
  85. 79
      src/utils/http.js

4
.env.development

@ -1,6 +1,6 @@
# 配置文档参考 https://taro-docs.jd.com/docs/next/env-mode-config
# TARO_APP_ID="开发环境下的小程序 AppID"
#TARO_APP_API="https://canola-tool.jiagutech.com/api"
TARO_APP_API="http://192.168.10.23:9761"
TARO_APP_API="http://uavedu.jiagutech.com/api"
TARO_APP_WS_API="ws://uavedu.jiagutech.com/api"

4
.env.production

@ -1,3 +1,3 @@
# TARO_APP_ID="生产环境下的小程序 AppID"
# TARO_APP_API="https://canola-tool.jiagutech.com/api"
TARO_APP_API="http://192.168.10.23:9761"
TARO_APP_API="http://uavedu.jiagutech.com/api"
TARO_APP_WS_API="ws://uavedu.jiagutech.com/api"

8
components.d.ts

@ -9,6 +9,7 @@ declare module 'vue' {
export interface GlobalComponents {
ExamQuestion: typeof import('./src/components/ExamQuestion.vue')['default']
ExamResult: typeof import('./src/components/ExamResult.vue')['default']
FlightDashboard: typeof import('./src/components/FlightDashboard.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCell: typeof import('@nutui/nutui-taro')['Cell']
@ -19,17 +20,20 @@ declare module 'vue' {
NutInfiniteloading: typeof import('@nutui/nutui-taro')['Infiniteloading']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutOverlay: typeof import('@nutui/nutui-taro')['Overlay']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRadio: typeof import('@nutui/nutui-taro')['Radio']
NutRadioGroup: typeof import('@nutui/nutui-taro')['RadioGroup']
NutRange: typeof import('@nutui/nutui-taro')['Range']
NutTabbar: typeof import('@nutui/nutui-taro')['Tabbar']
NutTabbarItem: typeof import('@nutui/nutui-taro')['TabbarItem']
NutTabPane: typeof import('@nutui/nutui-taro')['TabPane']
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutTag: typeof import('@nutui/nutui-taro')['Tag']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
RealTimeData: typeof import('./src/components/RealTimeData.vue')['default']
RoutePointInfo: typeof import('./src/components/RoutePointInfo.vue')['default']
SupervisionData: typeof import('./src/components/SupervisionData.vue')['default']
SupervisionSideData: typeof import('./src/components/SupervisionSideData.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TrackPlayback: typeof import('./src/components/TrackPlayback.vue')['default']
}

2301
package-lock.json

File diff suppressed because it is too large

2
package.json

@ -56,6 +56,8 @@
"@tarojs/taro": "3.6.35",
"@turf/turf": "^7.2.0",
"dayjs": "^1.11.13",
"gcoord": "^1.0.7",
"mathjs": "^14.4.0",
"pinia": "^2.2.6",
"popmotion": "^11.0.5",
"vue": "^3.2.40"

11
src/app.config.js

@ -1,9 +1,12 @@
export default defineAppConfig({
pages: [
'pages/login/index',
'pages/studentMap/index',
'pages/airfieldMap/index',
'pages/supervisionMap/index',
'pages/supervision/index',
'pages/airfield/index',
'pages/flightMap/index',
'pages/supervision/index',
'pages/returnTripMap/index',
'pages/routePlanMap/index',
'pages/home/index',
@ -19,4 +22,10 @@ export default defineAppConfig({
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
},
// permission: {
// "scope.device": {
// "desc": "需要获取设备信息以提供更好的服务"
// }
// }
// requiredBackgroundModes: ["audio"]
})

12
src/app.js

@ -1,19 +1,25 @@
import { createApp } from 'vue';
import store from './stores';
// import './app.less';
import './assets/iconfont.css'
import './assets/iconfont.less'
// import './common.less';
import { IconFont } from '@nutui/icons-vue-taro';
import Taro from "@tarojs/taro";
const App = createApp({
onShow(options) {
console.log('App Show', options);
onShow() {
// 保持屏幕常亮
Taro.setKeepScreenOn({
keepScreenOn: true
});
},
// 入口组件不需要实现 render 方法,即使实现了也会被 taro 所覆盖
})
App.config.warnHandler = () => {};
App.use(store);
App.use(IconFont);

BIN
src/assets/droneDisImg.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
src/assets/droneImg.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/errPoint.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

BIN
src/assets/fail.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

27
src/assets/iconfont.css

@ -1,27 +0,0 @@
@font-face {
font-family: "FontAwesome"; /* Project id 4868745 */
src: url('./iconfont.woff2?t=1742801623512') format('woff2'),
url('./iconfont.woff?t=1742801623512') format('woff'),
url('./iconfont.ttf?t=1742801623512') format('truetype');
}
.icon {
font-family: FontAwesome;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-stop:before {
content: "\e6b5";
}
.icon-pause:before {
content: "\e7fe";
}
.icon-play:before {
content: "\e6cf";
}

199
src/assets/iconfont.less

@ -0,0 +1,199 @@
@font-face {
font-family: "iconfont"; /* Project id 4857170 */
src: url('./iconfont.woff2?t=1746257417746') format('woff2'),
url('./iconfont.woff?t=1746257417746') format('woff'),
url('./iconfont.ttf?t=1746257417746') format('truetype')
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-angle-speed:before {
content: "\e8c2";
}
.icon-rotate360:before {
content: "\e6f3";
}
.icon-lock:before {
content: "\e861";
}
.icon-unlock:before {
content: "\e862";
}
.icon-enter-center:before {
content: "\e642";
}
.icon-heading:before {
content: "\e687";
}
.icon-tangent-speed:before {
content: "\e6e6";
}
.icon-stability:before {
content: "\e611";
}
.icon-throttle:before {
content: "\e743";
}
.icon-arrow-north:before {
content: "\e905";
}
.icon-playback:before {
content: "\e60a";
}
.icon-flag:before {
content: "\e66b";
}
.icon-air-route:before {
content: "\e809";
}
.icon-char8:before {
content: "\e6b1";
}
.icon-tangent-angle:before {
content: "\e6b0";
}
.icon-stopwatch:before {
content: "\e62b";
}
.icon-offset-h:before {
content: "\e753";
}
.icon-offset-v:before {
content: "\ee1a";
}
.icon-bg-arrow:before {
content: "\e6af";
}
.icon-arrow:before {
content: "\e6ad";
}
.icon-block:before {
content: "\e6ae";
}
.icon-stage:before {
content: "\e6fe";
}
.icon-lib:before {
content: "\e6bb";
}
.icon-add-point:before {
content: "\e7e4";
}
.icon-topic:before {
content: "\e60f";
}
.icon-mistake:before {
content: "\e600";
}
.icon-compass:before {
content: "\ee19";
}
.icon-speaker:before {
content: "\e652";
}
.icon-home-point:before {
content: "\e648";
}
.icon-waypoint:before {
content: "\e6ac";
}
.icon-measure:before {
content: "\e609";
}
.icon-location:before {
content: "\e608";
}
.icon-positioning:before {
content: "\e647";
}
.icon-zoom-in:before {
content: "\e6c4";
}
.icon-zoom-out:before {
content: "\e6c9";
}
.icon-uav4:before {
content: "\e736";
}
.icon-battery:before {
content: "\e6a9";
}
.icon-satellite:before {
content: "\e6aa";
}
.icon-fn:before {
content: "\e781";
}
.icon-setting:before {
content: "\e699";
}
.icon-scan:before {
content: "\e607";
}
.icon-help:before {
content: "\e63b";
}
.icon-book:before {
content: "\e638";
}
.icon-time:before {
content: "\e61d";
}
.icon-panel:before {
content: "\e60d";
}
.icon-visual:before {
content: "\e603";
}

BIN
src/assets/iconfont.ttf

Binary file not shown.

BIN
src/assets/iconfont.woff

Binary file not shown.

BIN
src/assets/iconfont.woff2

Binary file not shown.

BIN
src/assets/success.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

424
src/components/FlightDashboard.vue

@ -0,0 +1,424 @@
<script setup>
import { onMounted, ref, watch, onUnmounted } from 'vue';
import Taro from '@tarojs/taro';
const props = defineProps({
height: {
type: Number,
default: 0
},
pitch: {
type: Number,
default: 0
},
roll: {
type: Number,
default: 0
},
yaw: {
type: Number,
default: 0
},
homeDistance: {
type: Number,
default: 0
},
horizontalSpeed: {
type: Number,
default: 0
},
verticalSpeed: {
type: Number,
default: 0
},
homeAngle: {
type: Number,
default: 0
},
showHome: {
type: Boolean,
default: true
},
size: {
type: Number,
default: 150
}
});
const canvasContext = ref(null);
//
const drawDashboard = () => {
const ctx = canvasContext.value;
if (!ctx) return;
//
ctx.restore();
ctx.save();
const width = props.size;
const height = props.size;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - (props.showHome? 8 : 4);
//
ctx.clearRect(0, 0, width, height);
//
ctx.beginPath();
ctx.arc(centerX, centerY, radius + (props.showHome ? 4 : 2), 0, 2 * Math.PI);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = props.showHome? 8 : 4;
ctx.stroke();
//
ctx.save();
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.clip();
//
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate((props.roll * Math.PI) / 180);
const horizonY = props.pitch * 1.0;
//
ctx.beginPath();
ctx.rect(-width/2, -height/2, width, height/2 + horizonY);
ctx.fillStyle = '#1E90FF';
ctx.fill();
//
ctx.beginPath();
ctx.rect(-width/2, horizonY, width, height/2 - horizonY);
ctx.fillStyle = '#FFA500';
ctx.fill();
ctx.restore(); //
// 线
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate((props.yaw * Math.PI) / 180);
// 线
for (let i = 0; i < 36; i++) {
const angle = (i * 10 * Math.PI) / 180;
const isMainScale = i % 9 === 0; // 90
const isNorthDirection = i === 0;
const scaleLength = isMainScale ? 12 : 6;
// 线
ctx.beginPath();
ctx.moveTo(
Math.cos(angle - Math.PI/2) * (radius - scaleLength),
Math.sin(angle - Math.PI/2) * (radius - scaleLength)
);
ctx.lineTo(
Math.cos(angle - Math.PI/2) * radius,
Math.sin(angle - Math.PI/2) * radius
);
// 线
if (isNorthDirection) {
ctx.strokeStyle = '#FF0000'; // 线
ctx.lineWidth = 2;
} else {
ctx.strokeStyle = isMainScale ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = isMainScale ? 1.5 : 1;
}
ctx.shadowColor = 'rgba(255, 255, 255, 0.3)';
ctx.shadowBlur = 4;
ctx.stroke();
//
if (isMainScale) {
let direction = '';
if (i === 0) direction = 'N';
else if (i === 9) direction = 'E';
else if (i === 18) direction = 'S';
else if (i === 27) direction = 'W';
const textX = Math.cos(angle - Math.PI/2) * (radius - 20);
const textY = Math.sin(angle - Math.PI/2) * (radius - 20);
ctx.fillStyle = isNorthDirection ? '#FF0000' : '#000000';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
// ctx.textBaseline = Middle';
ctx.fillText(direction, textX, textY);
}
}
ctx.restore();
// 线
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate((props.roll * Math.PI) / 180);
// -180°180°5°
for (let angle = -180; angle <= 180; angle += 5) {
const y = horizonY - angle;
const isMainScale = angle % 30 === 0;
const isSecondaryScale = angle % 10 === 0;
const isZeroScale = angle === 0;
// 线
ctx.beginPath();
if (isZeroScale) {
ctx.moveTo(-25, y);
ctx.lineTo(-15, y);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
} else if (isMainScale) {
ctx.moveTo(-25, y);
ctx.lineTo(-15, y);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
} else if (isSecondaryScale) {
ctx.moveTo(-20, y);
ctx.lineTo(-15, y);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
} else {
ctx.moveTo(-18, y);
ctx.lineTo(-15, y);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 1;
}
ctx.stroke();
// 线
ctx.beginPath();
if (isZeroScale) {
ctx.moveTo(15, y);
ctx.lineTo(25, y);
} else if (isMainScale) {
ctx.moveTo(15, y);
ctx.lineTo(25, y);
} else if (isSecondaryScale) {
ctx.moveTo(15, y);
ctx.lineTo(20, y);
} else {
ctx.moveTo(15, y);
ctx.lineTo(18, y);
}
ctx.stroke();
// 30
if (angle % 30 === 0 && angle !== 0) {
ctx.fillStyle = '#ffffff';
ctx.font = '10px Arial';
//
ctx.textAlign = 'right';
ctx.fillText(angle.toString(), -28, y);
//
ctx.textAlign = 'left';
ctx.fillText(angle.toString(), 28, y);
}
// 0
if (isZeroScale) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px Arial';
// 0
ctx.textAlign = 'right';
ctx.fillText('0°', -28, y);
// 0
ctx.textAlign = 'left';
ctx.fillText('0°', 28, y);
}
}
ctx.restore();
// 姿
ctx.translate(centerX, centerY);
// "V"
ctx.beginPath();
//
ctx.moveTo(-30, 0);
ctx.lineTo(-7, 0);
// V
ctx.lineTo(0, 5);
ctx.lineTo(7, 0);
//
ctx.lineTo(30, 0);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.8)'; //
ctx.lineWidth = 2; // 线
ctx.stroke();
//
ctx.beginPath();
ctx.arc(0, 0, 1.5, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(255, 0, 0, 0.8)';
ctx.fill();
ctx.restore(); //
// Home
if (props.showHome) {
// ctx.save();
ctx.translate(centerX, centerY);
// Home
const homeAngle = (props.homeAngle * Math.PI) / 180;
const homeDistance = radius + 4; // Home
const homeX = Math.cos(homeAngle - Math.PI / 2) * homeDistance;
const homeY = Math.sin(homeAngle - Math.PI / 2) * homeDistance;
// Home
ctx.beginPath();
ctx.arc(homeX, homeY, 4, 0, 2 * Math.PI);
ctx.fillStyle = '#00ff00';
ctx.fill();
// Home
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 8px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; //
ctx.fillText('H', homeX, homeY);
ctx.restore();
}
};
onMounted(() => {
const query = Taro.createSelectorQuery();
query.select('#leafer')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
//
const dpr = Taro.getSystemInfoSync().pixelRatio || 1;
// canvas
canvas.width = props.size * dpr;
canvas.height = props.size * dpr;
//
ctx.scale(dpr, dpr);
//
//
canvasContext.value = ctx;
//
drawDashboard();
});
//
watch(() => ({
pitch: props.pitch,
roll: props.roll,
yaw: props.yaw,
homeAngle: props.homeAngle,
showHome: props.showHome,
size: props.size //
}), () => {
drawDashboard();
}, { deep: true });
});
// watch(() => props, (newProps) => {
// drawDashboard();
// }, {deep: true});
onUnmounted(() => {
//
})
</script>
<template>
<div :class="s.root">
<canvas
id="leafer"
type="2d"
:style="{ width: `${props.size}px`, height: `${props.size}px` }"
></canvas>
<div class="attitude-info">
<div class="attitude-item">
<span class="info-label">俯仰</span>
<span class="info-value">{{ pitch }}°</span>
</div>
<div class="attitude-item">
<span class="info-label">横滚</span>
<span class="info-value">{{ roll }}°</span>
</div>
<div class="attitude-item">
<span class="info-label">偏航</span>
<span class="info-value">{{ yaw }}°</span>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
// position: absolute;
// top: 0;
// bottom: 0;
// right: 0;
// left: 0;
z-index: 2;
display: flex;
align-items: flex-start;
gap: 16px;
// border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
:global {
#leafer {
width: 150px;
height: 150px;
flex-shrink: 0;
}
.attitude-info {
display: flex;
gap: 8px;
padding: 2px;
// background: rgba(0, 0, 0, 0.6);
// border-radius: 2px;
width: 100%;
justify-content: center;
.attitude-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 40px;
.info-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.8);
}
.info-value {
font-size: 10px;
color: #ffffff;
font-family: monospace;
}
}
}
}
}
</style>

2
src/components/RealTimeData.vue

@ -3,7 +3,7 @@ import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
import { toFixed } from '../utils/helpers';
import { formatTime } from '../utils/helpers';
import deviceCruise from '../core/deviceCruise';
import deviceCruise from '../core/useDeviceCruise';
const isVisible = ref(true);
// const toggleVisibility = () => {

324
src/components/SupervisionData.vue

@ -1,324 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
import { toFixed } from '../utils/helpers';
import { formatTime } from '../utils/helpers';
import deviceCruise from '../core/deviceCruise';
const isVisible = ref(true);
const info = computed(() => deviceCruise.timelyData);
//
const flightTime = computed(() => {
const startTime = deviceCruise.startTime;
if (!startTime) return '00:00:00';
const duration = Math.floor((Date.now() - startTime) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
//
const errorPoints = ref([
//
// { position: { lat: 39.908692, lng: 116.397477 }, type: '', time: '2024-01-20 10:30:00' }
]);
</script>
<template>
<div :class="[s.root, { [s.hidden]: !isVisible }]">
<div class="nav">
<div :class="{ 'toggle-btn': true, 'is-active': !isVisible }" @click="() => isVisible = !isVisible">
<Left v-if="isVisible" style="font-size: 14px;" />
<Right style="font-size: 14px;" v-else />
</div>
</div>
<div class="container">
<!-- 基本状态 -->
<div class="section">
<div class="section-title">基本状态</div>
<div class="text-row">
<span class="label">电量</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ info.battery || 0 }}</small>
<span class="unit">%</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">卫星数</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ info.satellites || 0 }}</small>
<span class="unit"></span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">飞行时间</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ flightTime }}</small>
</div>
</span>
</div>
</div>
<!-- 位置信息 -->
<div class="section">
<div class="section-title">位置信息</div>
<div class="text-row">
<span class="label">经度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.lng, 7) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">纬度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.lat, 7) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">高度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.altitude, 2) }}</small>
<span class="unit"></span>
</div>
</span>
</div>
</div>
<!-- 姿态信息 -->
<div class="section">
<div class="section-title">姿态信息</div>
<div class="text-row">
<span class="label">俯仰角</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.pitch, 2) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">横滚角</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.roll, 2) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">航向角</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.yaw, 2) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
</div>
<!-- 偏移信息 -->
<div class="section">
<div class="section-title">偏移信息</div>
<div class="text-row">
<span class="label">切线角</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.tangentAngle, 2) }}</small>
<span class="unit">°</span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">水平偏移</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.horizontalOffset, 2) }}</small>
<span class="unit"></span>
</div>
</span>
</div>
<div class="text-row">
<span class="label">高度偏移</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.verticalOffset, 2) }}</small>
<span class="unit"></span>
</div>
</span>
</div>
</div>
<!-- 错误点列表 -->
<div class="section" v-if="errorPoints.length > 0">
<div class="section-title">错误点列表</div>
<div class="error-list">
<div v-for="(point, index) in errorPoints" :key="index" class="error-item">
<div class="error-type">{{ point.type }}</div>
<div class="error-position">
<small>{{ toFixed(point.position.lat, 7) }}, {{ toFixed(point.position.lng, 7) }}</small>
</div>
<div class="error-time">
<small>{{ point.time }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0px;
top: 0px;
bottom: 0px;
background-color: rgba(0, 0, 0, 0.6);
padding: 8px;
border-radius: 8px;
color: white;
font-size: 11px;
width: fit-content;
min-width: 140px;
z-index: 2;
transition: transform 0.3s ease;
&.hidden {
transform: translateX(calc(-100% - 5px));
}
:global {
.container {
// display: flex;
// flex-direction: column;
// gap: 12px;
box-sizing: border-box;
overflow: auto;
height: 100%;
}
.nav {
position: absolute;
right: -20px;
top: 6px;
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 0 4px 4px 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
.section {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 12px;
font-weight: 500;
color: #fff;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
}
.text-row {
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 2px;
&:last-child {
margin-bottom: 0;
}
.label {
min-width: 50px;
color: rgba(255, 255, 255, 0.8);
font-size: 10px;
}
.value {
color: white;
}
.value-unit {
display: inline-flex;
align-items: baseline;
gap: 1px;
.value {
font-weight: 500;
}
.unit {
font-size: 0.85em;
opacity: 0.8;
}
small {
font-size: 0.85em;
}
}
}
.error-list {
.error-item {
margin-bottom: 8px;
padding: 6px;
background-color: rgba(255, 0, 0, 0.2);
border-radius: 4px;
&:last-child {
margin-bottom: 0;
}
.error-type {
font-weight: 500;
margin-bottom: 2px;
}
.error-position,
.error-time {
font-size: 0.85em;
opacity: 0.8;
}
}
}
}
}
</style>

214
src/components/SupervisionSideData.vue

@ -1,214 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
import deviceCruise from '../core/deviceCruise';
const isVisible = ref(true);
const info = computed(() => deviceCruise.timelyData);
//
const studentInfo = ref({
name: '张三', //
droneId: 'UAV-001', //
});
//
const countdown = ref({
phase: '起飞阶段', //
remainingTime: '05:00', //
});
//
const voiceContent = ref('请注意保持安全高度');
</script>
<template>
<div :class="[s.root, { [s.hidden]: !isVisible }]">
<div class="nav">
<div :class="{ 'toggle-btn': true, 'is-active': !isVisible }" @click="() => isVisible = !isVisible">
<Right v-if="isVisible" style="font-size: 14px;" />
<Left style="font-size: 14px;" v-else />
</div>
</div>
<div class="container">
<div class="container-2">
<!-- 学员信息 -->
<div class="section">
<div class="section-title">学员信息</div>
<div class="text-row">
<span class="label">姓名</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ studentInfo.name }}</small>
</div>
</span>
</div>
<div class="text-row">
<span class="label">飞机编号</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ studentInfo.droneId }}</small>
</div>
</span>
</div>
</div>
<!-- 阶段信息 -->
<div class="section">
<div class="section-title">阶段信息</div>
<div class="text-row">
<span class="label">当前阶段</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ countdown.phase }}</small>
</div>
</span>
</div>
<div class="text-row">
<span class="label">剩余时间</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ countdown.remainingTime }}</small>
</div>
</span>
</div>
</div>
<!-- 语音播报 -->
<div class="section">
<div class="section-title">语音播报</div>
<div class="voice-content">
{{ voiceContent }}
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 0px;
top: 0px;
bottom: 0px;
background-color: rgba(0, 0, 0, 0.6);
padding: 8px;
border-radius: 8px;
color: white;
font-size: 11px;
width: fit-content;
min-width: 140px;
z-index: 2;
transition: transform 0.3s ease;
&.hidden {
transform: translateX(calc(100% + 5px));
}
:global {
.container {
// box-sizing: border-box;
overflow: auto;
height: 100%;
.container-2 {
// display: flex;
// flex-direction: column;
// gap: 8px;
// padding: 4px;
height: 102%;
overflow: auto;
}
}
.nav {
position: absolute;
left: -20px;
top: 6px;
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 4px 0 0 4px;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
.section {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 12px;
font-weight: 500;
color: #fff;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
}
.text-row {
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 2px;
&:last-child {
margin-bottom: 0;
}
.label {
min-width: 50px;
color: rgba(255, 255, 255, 0.8);
font-size: 10px;
}
.value {
color: white;
}
.value-unit {
display: inline-flex;
align-items: baseline;
gap: 1px;
.value {
font-weight: 500;
}
small {
font-size: 0.85em;
}
}
}
.voice-content {
padding: 6px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 0.85em;
line-height: 1.4;
}
}
}
</style>

10
src/components/TabBar.vue

@ -1,7 +1,7 @@
<script setup>
import { eventCenter, getCurrentInstance, reLaunch } from '@tarojs/taro'
import {onMounted, ref, h, computed} from "vue";
import { Category, My, Eye, Horizontal } from '@nutui/icons-vue-taro';
import { Home, Category, My, Eye, Horizontal } from '@nutui/icons-vue-taro';
import { useAuthStore } from "../stores";
import { storeToRefs } from 'pinia';
@ -11,12 +11,14 @@
const tabList = computed(() => [
...(isStudent.value ? [
{ title: '首页', icon: h(Category), path: '/pages/home/index' },
{ title: '监管', icon: h(Eye), path: '/pages/supervision/index' },
{ title: '首页', icon: h(Home), path: '/pages/home/index' },
// { title: '', icon: h(Eye), path: '/pages/supervision/index' },
// { title: '', icon: h(Category), path: '/pages/airfield/index' },
{ title: '我的', icon: h(My), path: '/pages/own/index' }
] : [
{ title: '监', icon: h(Eye), path: '/pages/supervision/index' },
{ title: '监', icon: h(Eye), path: '/pages/supervision/index' },
{ title: '记录', icon: h(Horizontal), path: '/pages/home/index' },
{ title: '场地', icon: h(Category), path: '/pages/airfield/index' },
{ title: '我的', icon: h(My), path: '/pages/own/index' }
]),
]);

176
src/components/TrackPlayback.vue

@ -1,24 +1,31 @@
<script setup>
import { computed, ref } from 'vue';
import deviceCruise from '../core/deviceCruise';
import { PlayStop, PlayStart, Refresh } from '@nutui/icons-vue-taro';
// import deviceCruise from '../core/useDeviceCruise';
import { PlayStart, PlayStop } from '@nutui/icons-vue-taro';
const props = defineProps({
info: {
type: Object,
default: () => ({})
}
})
//
const ready = computed(() => deviceCruise.ready);
const speedRate = computed(() => deviceCruise.speedRate);
const totalTime = computed(() => deviceCruise.totalTime);
const ready = computed(() => props.info?.ready);
// const speedRate = computed(() => props.info?.speedRate);
const totalTime = computed(() => props.info?.totalTime);
const currentTime = computed({
get() {
return deviceCruise.elapsedMs;
return props.info?.elapsedMs;
},
set(val) {
// console.log('aaaaa', val);
// deviceCruise.handlePause();
deviceCruise.setCurrentTime(val);
props.info?.setCurrentTime(val);
},
});
const showPlayButton = computed(() => deviceCruise.isPaused || deviceCruise.isStopped);
const showPlayButton = computed(() => props.info?.isPaused);
//
function formatTime(ms) {
@ -29,30 +36,34 @@ function formatTime(ms) {
}
//
const currentTimeDisplay = computed(() => formatTime(deviceCruise.elapsedMs));
const totalTimeDisplay = computed(() => formatTime(totalTime.value));
const currentTimeDisplay = computed(() => formatTime(props.info?.elapsedMs));
const totalTimeDisplay = computed(() => formatTime(props.info?.totalTime));
//
function onChangeSpeedRate() {
const newSpeedRate = speedRate.value >= 8 ? 1 : speedRate.value * 2;
deviceCruise.setSpeedRate(newSpeedRate);
}
// //
// function onChangeSpeedRate() {
// const newSpeedRate = speedRate.value >= 8 ? 1 : speedRate.value * 2;
// deviceCruise.setSpeedRate(newSpeedRate);
// }
function onPlay() {
deviceCruise.handlePlay();
props.info?.play();
}
function onPause() {
deviceCruise.handlePause();
}
function onStop() {
deviceCruise.handleStop();
props.info?.pause();
}
</script>
<template>
<view :class="s.root">
<view :class="s.root" :catch-move="true">
<view class="btn-box">
<view class="btn" v-if="showPlayButton" @click="onPlay">
<PlayStart />
</view>
<view class="btn" v-else @click="onPause">
<PlayStop />
</view>
</view>
<view class="slider-container">
<nut-range
v-model="currentTime"
@ -63,24 +74,10 @@ function onStop() {
:step="1"
/>
<view class="time-display">
<text>{{ currentTimeDisplay }}</text>
<text>{{ currentTimeDisplay }}</text>/
<text>{{ totalTimeDisplay }}</text>
</view>
</view>
<view class="controls">
<view class="btn" @click="onChangeSpeedRate" style="color: #fff;">
{{ speedRate }} x
</view>
<view class="btn" v-if="showPlayButton" @click="onPlay">
<PlayStart />
</view>
<view class="btn" v-else @click="onPause">
<PlayStop />
</view>
<view class="btn" @click="onStop">
<Refresh />
</view>
</view>
</view>
</template>
@ -88,107 +85,46 @@ function onStop() {
.root {
pointer-events: visible;
position: absolute;
left: 10rpx;
right: 10rpx;
bottom: 5rpx;
left: 150px;
right: 150px;
bottom: 40px;
z-index: 999;
background: rgba(0, 0, 0, 0.5);
padding: 10rpx 15rpx 10rpx 15rpx;
border-radius: 8rpx;
//padding: 10rpx 15rpx 10rpx 15rpx;
//border-radius: 8rpx;
display: flex;
// flex-direction: row;
align-items: center;
gap: 20rpx;
//gap: 20rpx;
:global {
.slider-container {
flex: 4;
.nut-range-button {
width: 16rpx;
height: 16rpx;
}
}
.time-display {
display: flex;
justify-content: space-between;
font-size: 16rpx;
color: #fff;
margin-top: 5rpx;
.btn-box {
color: white;
//background: rgba(0, 0, 0, 0.5);
margin-right: 8px;
}
.controls {
// flex: 2;
display: flex;
align-items: center;
// justify-content: flex-end;
gap: 10rpx;
.btn {
.slider-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background-color: #0069ca!important;
min-width: 30rpx;
font-size: 16rpx;
height: 40rpx;
width: 40rpx;
border-radius: 8rpx;
&:active {
background-color: #0056b3!important;
}
.nut-icon {
//width: 12px;
//height: 12px;
}
}
.nut-range-button {
width: 10px;
height: 10px;
}
.play-controls {
// display: flex;
// gap: 10rpx;
.nut-range {
height: 4px;
}
// .nut-button {
// min-width: 30rpx;
// height: 30rpx;
// padding: 0 6rpx;
// display: flex;
// align-items: center;
// justify-content: center;
// color: #fff;
// // background-color: #1890ff !important;
// }
.iconfont {
font-size: 16rpx;
}
.time-display {
display: flex;
justify-content: space-between;
font-size: 16rpx;
//display: flex;
//justify-content: space-between;
font-size: 11px;
font-weight: bold;
color: #fff;
margin-top: 8rpx;
}
.icon-play:before {
content: '\e87c';
}
.icon-pause:before {
content: '\e87d';
}
.icon-stop:before {
content: '\e87e';
white-space: nowrap;
margin-left: 10px;
}
}
}

18
src/config/fcSystemStatus.js

@ -0,0 +1,18 @@
/**
* 飞控系统状态
*/
// 依据https://mavlink.io/en/messages/common.html#MAV_STATE
export const FC_SYSTEM_STATUS = new Map([
['MAV_STATE_UNINIT', '未初始化'],
['MAV_STATE_BOOT', '启动中'],
['MAV_STATE_CALIBRATING', '校准中'],
['MAV_STATE_STANDBY', '已上锁'],
['MAV_STATE_ACTIVE', '已解锁'],
['MAV_STATE_CRITICAL', '临界状态'],
['MAV_STATE_EMERGENCY', '紧急状态'],
['MAV_STATE_POWEROFF', '关闭'],
['MAV_STATE_FLIGHT_TERMINATION', '终止中'],
]);
export const FC_SYSTEM_STATUS_LABEL = [...FC_SYSTEM_STATUS.values()];

24
src/config/flyModeMap.js

@ -0,0 +1,24 @@
/**
* 飞行模式
*/
export const FLY_MODE = {
3: '姿态模式',
4: '定点模式',
10: '自动起飞',
11: '自动悬停',
12: '自动返航',
15: '自动巡航',
18: '指点飞行',
19: '降落',
20: '迫降',
21: '跟随',
23: '航点环绕',
24: '动平台起飞',
25: '动平台降落',
26: '自主避障',
27: '控制',
28: '队形编队',
};
export default {};

29
src/config/gpsFixTypeMap.js

@ -0,0 +1,29 @@
/**
* GPS定位类型
*/
// 适用于GPS_INPUT(https://mavlink.io/en/messages/common.html#GPS_INPUT)
export const GPS_FIX_TYPE = {
0: '无GPS',
1: '无定位',
2: '经纬定位',
3: '经纬高定位',
4: '差分GPS',
5: 'RTK定位',
};
// 适用于GPS2_RAW(https://mavlink.io/en/messages/common.html#GPS2_RAW)
// 依据https://mavlink.io/en/messages/common.html#GPS_FIX_TYPE
export const GPS_FIX_TYPE2 = new Map([
['GPS_FIX_TYPE_NO_GPS', '无GPS'],
['GPS_FIX_TYPE_NO_FIX', '无定位'],
['GPS_FIX_TYPE_2D_FIX', '经纬定位'],
['GPS_FIX_TYPE_3D_FIX', '经纬高定位'],
['GPS_FIX_TYPE_DGPS', '差分GPS'],
['GPS_FIX_TYPE_RTK_FLOAT', 'RTK浮点解'],
['GPS_FIX_TYPE_RTK_FIXED', 'RTK固定解'],
['GPS_FIX_TYPE_STATIC', '静态固定定位'],
['GPS_FIX_TYPE_PPP', '精密单点定位'],
]);
export const GPS_FIX_TYPE2_LABEL = [...GPS_FIX_TYPE2.values()];

11
src/config/stageMap.js

@ -0,0 +1,11 @@
export const STAGE_MAP = {
spin: {
text: '自旋',
},
shape8: {
text: '八字飞行',
},
hover: {
text: '悬停',
},
}

126
src/config/tipTextMap.js

@ -0,0 +1,126 @@
export const TIP_TEXT = {
"first_start_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745815890849334086.mp3",
text: "考试开始,请操作无人机飞行至高度大于1.5米,小于5米",
},
"height_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745815948315941248.mp3",
text: "停至高度大于1.5米,小于5米",
},
"enter_center_time_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745815671188591010.mp3",
text: "请进入中心桶,一分钟倒计时",
},
"enter_center_time_out": {
icon: 'stopwatch',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816018284105950.mp3",
text: "操作失败,进入中心桶已超时"
},
"hover_tail_hints":{
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816051153798301.mp3",
text: "请悬停对尾"
},
"second_start_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816095288763692.mp3",
text: "第2次考试开始"
},
"third_start_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816133723481929.mp3",
text: "第3次考试开始"
},
"height_hints2": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816207762815424.mp3",
text: "请升高至1.5米"
},
"fail_horizontal_large": {
icon: 'offset-h',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816356733350242.mp3",
text: "操作失败,水平偏差过大"
},
"fail_vertical_large": {
icon: 'offset-v',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816464302514672.mp3",
text: "操作失败,垂直偏差过大",
},
"fail_direction_reverse": {
icon: 'rotate360',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816482427120413.mp3",
text: "操作失败,无人机回转"
},
"fail_spin_time_out": {
icon: 'stopwatch',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816508442043575.mp3",
text: "操作失败,自旋已超时"
},
"spin_first_clock_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816546262459206.mp3",
text: "第一圈完毕,请顺时针旋转"
},
"spin_first_rclock_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816563363697085.mp3",
text: "第一圈完毕,请逆时针旋转"
},
"spin_fail_clock_time_out": {
icon: 'stopwatch',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816583793120597.mp3",
text: "操作失败,顺时针自旋已超时"
},
"spin_fail_rclock_time_out": {
icon: 'stopwatch',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816602031257891.mp3",
text: "操作失败,逆时针自旋已超时"
},
"flight_8_start_hints": {
fileUrl: 'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745814185096733360.mp3',
text: "8字飞行开始,3分钟倒计时",
},
"fail_8_time_out": {
icon: 'stopwatch',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816626528127642.mp3",
text: "操作失败,8字飞行已超时"
},
"fail_speed_large": {
icon: 'tangent-speed',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816721721001188.mp3",
text: "操作失败,切线速度过高"
},
"fail_speed_low": {
icon: 'tangent-speed',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816653069185482.mp3",
text: "操作失败,切线速度过低"
},
"fail_yaw_large": {
icon: 'heading',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816746696830596.mp3",
text: "操作失败,航向偏差过大"
},
"exam_pass_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816765576959891.mp3",
text: "考试通过"
},
"landing": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816825729131461.mp3",
text: "请降落到圈外"
},
"pasue_time_out": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816842033628886.mp3",
text: "暂停已超时"
},
"exam_fail_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745816864370674197.mp3",
text: "考试未通过"
},
"spin_start_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745822423415207696.mp3",
text: '自旋开始,一分钟倒计时'
},
"enter_90_hints": {
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745822448671992601.mp3",
text: '请进入中心桶, 90秒倒计时'
},
'angle_speed_low': {
icon: 'angle-speed',
fileUrl: "http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745829747486669352.mp3",
text: '操作失败,角速度过低'
}
}

112
src/config/urls.js

@ -3,94 +3,68 @@
*/
import { buildURL } from '../utils/helpers';
const { TARO_APP_API: BASE_URL } = process.env; // 获取环境变量
// const { TARO_APP_API: BASE_URL } = import.meta; // 获取环境变量
// const { TARO_APP_API: BASE_URL, TARO_APP_WS_API: WS_BASE_URL } = import.meta; // 获取环境变量
// console.log(BASE_URL);
// 上传
// export const UPLOAD = `${BASE_URL}/v1/files/upload`;
export const UPLOAD = `http://192.168.10.23:9762/v1/files/upload`;
export const UPLOAD = `${BASE_URL}/train/v1/files/upload`;
// 用户登录
export const LOGIN_WITH_PASSWORD = `${BASE_URL}/user/token`;
export const WECHAT_AUTH_URL = `${BASE_URL}/wechat/mini/auth`;
export const GET_WECHAT_USERINFO = `${BASE_URL}/wechat/mini/getWxUserInfo`;
export const LOGIN_WITH_PASSWORD = `${BASE_URL}/user/user/token`;
export const STUDENT_LOGIN_WITH_PASSWORD = `${BASE_URL}/user/student/token`;
export const WECHAT_AUTH_URL = `${BASE_URL}/user/wechat/mini/auth`;
export const GET_WECHAT_USERINFO = `${BASE_URL}/user/wechat/mini/getWxUserInfo`;
//学生信息
export const UPDATE_STUDENT = (id) => buildURL(`${BASE_URL}/v1/students/{id}`, id);
export const UPDATE_STUDENT = (id) => buildURL(`${BASE_URL}/user/v1/students/{id}`, id);
//人员信息
export const UPDATE_MEMBER = (id) => buildURL(`${BASE_URL}/v1/users/{id}`, id);
export const UPDATE_MEMBER = (id) => buildURL(`${BASE_URL}/user/v1/users/{id}`, id);
// 讲评管理
// export const GET_EVALUATION_LIST = `${BASE_URL}/v1/evaluations`;
// export const CREATE_EVALUATION = `${BASE_URL}/v1/evaluations`;
// export const UPDATE_EVALUATION = `${BASE_URL}/v1/evaluations`;
// export const GET_EVALUATION_DETAIL = (id) => buildURL(`${BASE_URL}/v1/evaluations/{id}`, id);
export const GET_EVALUATION_LIST = `http://192.168.10.23:9762/v1/evaluations`;
export const CREATE_EVALUATION = `http://192.168.10.23:9762/v1/evaluations`;
export const UPDATE_EVALUATION = `http://192.168.10.23:9762/v1/evaluations`;
export const GET_EVALUATION_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/evaluations/{id}`, id);
export const REPLY_EVALUATION = 'http://192.168.10.23:9762/v1/evaluations/reply';
export const GET_EVALUATION_LIST = `${BASE_URL}/train/v1/evaluations`;
export const CREATE_EVALUATION = `${BASE_URL}/train/v1/evaluations`;
export const UPDATE_EVALUATION = `${BASE_URL}/train/v1/evaluations`;
export const GET_EVALUATION_DETAIL = (id) => buildURL(`${BASE_URL}/train/v1/evaluations/{id}`, id);
export const REPLY_EVALUATION = `${BASE_URL}/train/v1/evaluations/reply`;
// 实践飞行管理
// export const GET_FLIGHT_LIST = `${BASE_URL}/v1/flightRecords`;
// export const GET_FLIGHT_DETAIL = (id) => buildURL(`${BASE_URL}/v1/flightRecords/{id}`, id);
// export const GET_FLIGHT_TRACKS = (id) => buildURL(`${BASE_URL}/v1/flightRecords/{id}/tracks`, id);
export const GET_FLIGHT_LIST = `http://192.168.10.23:9762/v1/flightRecords`;
export const GET_FLIGHT_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/flightRecords/{id}`, id);
export const GET_FLIGHT_TRACKS = (id) => buildURL(`http://192.168.10.23:9762/v1/flightRecords/{id}/tracks`, id);
export const GET_FLIGHT_LIST = `${BASE_URL}/train/v1/flightRecords`;
export const GET_FLIGHT_DETAIL = (id) => buildURL(`${BASE_URL}/train/v1/flightRecords/{id}`, id);
export const GET_FLIGHT_TRACKS = (id) => buildURL(`${BASE_URL}/train/v1/flightRecords/{id}/tracks`, id);
export const STUDENT_BIND_DRONE = `${BASE_URL}/user/v1/drones/qrCode/scan`;
// 航线管理
// export const GET_ROUTE_PLAN_LIST = `${BASE_URL}/v1/routePlans`;
// export const GET_ROUTE_PLAN_DETAIL = (id) => buildURL(`${BASE_URL}/v1/routePlans/{id}`, id);
export const GET_ROUTE_PLAN_LIST = `http://192.168.10.23:9762/v1/routePlans`;
export const GET_ROUTE_PLAN_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/routePlans/{id}`, id);
export const GET_ROUTE_PLAN_LIST = `${BASE_URL}/train/v1/routePlans`;
export const GET_ROUTE_PLAN_DETAIL = (id) => buildURL(`${BASE_URL}/train/v1/routePlans/{id}`, id);
// 应急返航管理
// export const GET_RETURN_TRIP_LIST = `${BASE_URL}/v1/returnTrips`;
// export const GET_RETURN_TRIP_DETAIL = (id) => buildURL(`${BASE_URL}/v1/returnTrips/{id}`, id);
export const GET_RETURN_TRIP_LIST = `http://192.168.10.23:9762/v1/returnTrips`;
export const GET_RETURN_TRIP_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/returnTrips/{id}`, id);
// 用户信息
export const GET_USER_INFO = `${BASE_URL}/user/userInfo`;
export const GET_RETURN_TRIP_LIST = `${BASE_URL}/train/v1/returnTrips`;
export const GET_RETURN_TRIP_DETAIL = (id) => buildURL(`${BASE_URL}/train/v1/returnTrips/{id}`, id);
// 识别记录
export const CREATE_RECORD = `${BASE_URL}/record/createRecord`;
// 环境参数管理
export const GET_ENV_LIST = `${BASE_URL}/train/v1/envs`;
// export const CREATE_RECORD = `${BASE_URL}/record/upload`;
export const GET_RECORD_LIST = `${BASE_URL}/record/page`;
export const DELETE_RECORD = `${BASE_URL}/record/delete/{recordId}`;
export const UPDATE_RECORD = `${BASE_URL}/record/update`;
export const GET_RECORD_DETAIL = `${BASE_URL}/record/getRecordDetail/{recordId}`;
export const UPDATE_RECORD_DETAIL = `${BASE_URL}/record/updateRecordContent`;
export const EXPORT_RECORD = `${BASE_URL}/record/export/{recordId}`;
export const SET_SCALE = `${BASE_URL}/record/scale`;
// 考试标准管理
export const GET_EXAM_LIST = `${BASE_URL}/train/v1/exams`;
// 任务
export const GET_TASK_LIST = `${BASE_URL}/record/tasks`;
export const GET_TASK_PROGRESS = `${BASE_URL}/record/taskProgress/{taskId}`;
// ws 飞机实时数据
// export const GET_DRONE_REAL_TIME_DATA = `${WS_BASE_URL}/data/ws`;
export const GET_DRONE_REAL_TIME_DATA = `ws://uavedu.jiagutech.com/api/data/ws`;
// 模型列表
export const GET_MODEL_LIST = `${BASE_URL}/record/models`;
export const UPDATE_USER = `${BASE_URL}/user/update`;
export const SEARCH_REGION_FARMER = `${BASE_URL}/user/listFarmerByRegionCode`;
export const UPDATE_FARMER = `${BASE_URL}/user/updateFarmer`;
// 场地管理
export const GET_AIRFIELDS_LIST = `${BASE_URL}/train/v1/airfields`;
export const GET_AIRFIELDS_DETAIL = (id) => buildURL(`${BASE_URL}/train/v1/airfields/{id}`, id);
export const CREATE_AIRFIELD = `${BASE_URL}/train/v1/airfields`
export const GET_AIRFIELDS_OF_STUDENT = `${BASE_URL}/train/v1/airfields/student/current`;
export const UPDATE_AIRFIELD = (id) => buildURL(`${BASE_URL}/train/v1/airfields/{id}`, id)
export const DELETE_AIRFIELD = (id) => buildURL(`${BASE_URL}/train/v1/airfields/{id}`, id)
export const AIRFIELD_BIND_CLASS = `${BASE_URL}/train/v1/airfields/class/airfield`;
// 作业
export const GET_WORK_RECORD_LIST = `${BASE_URL}/job/page`;
export const GET_TRACK_LIST = `${BASE_URL}/job/tracks/{jobId}`;
export const GET_WORK_TYPE_LIST = `${BASE_URL}/job/types`;
export const GET_WORKING_DETAIL = `${BASE_URL}/job/inWorking`;
export const UPDATE_WORK_STATUS = `${BASE_URL}/job/status`;
export const CREATE_WORK = `${BASE_URL}/job/start`;
// 无人机
export const GET_DRONE_LIST = `${BASE_URL}/user/v1/drones`;
// 设备
export const GET_DEVICE_LIST = `${BASE_URL}/device/page`;
export const GET_ONLINE_DEVICES = `${BASE_URL}/device/onlineAndRound`;
// 班级
export const GET_CLASS_LIST = `${BASE_URL}/user/v1/classes`
// 地区
export const GET_REGION_CHILDREN = `${BASE_URL}/region/getChildren`;
// 执照等级
export const GET_LICENSE_GRADES_LIST = `${BASE_URL}/user/licenseGrades`;

296
src/core/deviceCruise.js

@ -1,296 +0,0 @@
/**
* 设备按轨迹巡航
*/
import { reactive, ref } from 'vue';
import { interpolate } from 'popmotion';
// import * as turf from '@turf/turf';
import deviceIcon from '../assets/deviceIcon.png';
const markerId = 999;
class DeviceCruise {
_markerOptins = {
id: markerId,
iconPath: deviceIcon,
width: 32,
height: 32,
anchor: { x: 0.5, y: 0.5 }
}
_that = null;
// 地图实例
_map = null;
// 单条轨迹数据
_dataSource = {};
// 巡航速率
speedRate = 1;
// 动画计时器
_timer = null;
// 上一帧时间点
_lastFrameAt = 0;
// 累计播放时长(毫秒数)
elapsedMs = 0;
// 每帧期望间隔(毫秒数,实际间隔取决于浏览器fps)
_fpsInterval = 1000 / 30;
// 上一帧时间戳
_lastFrameTimestamp = 0;
// 巡航到的时间点数据
timelyData = {};
isPlaying = false;
isPaused = false;
isStopped = true;
// currentIndex = 0;
// 是否准备完毕
get ready() {
return Object.keys(this._that._dataSource).length > 0;
}
get _points() {
const { points } = this._that._dataSource;
return points || [];
}
// 基准时间点(从0开始的毫秒数)
get _datumTime() {
const [{ timestamp: startTs } = {}] = this._that._points || [];
return this._that._points.map(({ timestamp }) => timestamp - startTs);
}
// 轨迹总时间(毫秒数)
get totalTime() {
return this._that._datumTime[this._that._datumTime.length - 1] || 0;
}
// 是否已经开始播放了
get isStarted() {
return this._that.isPlaying || this._that.isPaused;
}
constructor() {
this._that = reactive(this);
return this._that;
}
// 设置地图实例
setMap(mapInstance) {
this._that._map = mapInstance;
}
// 载入轨迹数据(point中必须包含lng, lat, timestamp, yaw)
loadTrack({ id, points, ...others }) {
// if (!this._that._that._map) {
// throw new Error('请先设置地图实例');
// }
this._that._dataSource = {
id,
points: (points || []).map(item => ({ ...item })),
...others,
};
this._that._reset();
this._that._initDevice();
}
// 设置速率
setSpeedRate(val) {
this._that.speedRate = val;
}
_setting = false;
// 设置当前巡航时间点
setCurrentTime(ms) {
if (ms < 0 || ms > this._that.totalTime) return;
if (this._that.isPlaying) {
this._that.isPlaying = false;
this._that.isPaused = true;
}
if (this._that._setting) return;
this._that._setting = true;
this._that.elapsedMs = ms;
const index = this._that._datumTime.findIndex(item => item >= ms);
this._that.currentIndex = index;
const point = this._that._points[index];
const { lng, lat, yaw } = point;
this._that._map.removeMarkers({
markerIds: [markerId],
success: () => {
// this._that._renderDevice();
this._that._map.addMarkers({
markers: [{
...this._that._markerOptins,
latitude: lat,
longitude: lng,
rotate: yaw || 0,
}],
success: () => {
this._that._setting = false;
}
});
}
});
}
// 获取指定毫秒处的数据值
_getTimelyData(ms = 0) {
const genTimelyData = interpolate(this._that._datumTime, this._that._points);
// const index = this._that._datumTime.findIndex(item => item >= ms);
// console.log('genTimelyData', genTimelyData(ms));
// if (index === -1) {
// return {};
// }
return genTimelyData(ms);
// return this._that._points[index] || {};
}
_reset() {
this._that.isPlaying = false;
this._that.isPaused = false;
this._that.isStopped = true;
this._that.speedRate = 1;
this._that._lastFrameAt = 0;
this._that.elapsedMs = 0;
this._that._lastFrameTimestamp = 0;
}
_initDevice() {
const [point] = this._that._points;
if (!point) {
return;
}
const { lng, lat, yaw } = point;
this._that._map.addMarkers({
markers: [{
...this._that._markerOptins,
latitude: lat,
longitude: lng,
rotate: yaw || 0,
}]
})
}
_clearDevice() {
// 清除timelyData即可
this._that.timelyData = {};
this._that._map.removeMarkers({
markerIds: [markerId]
});
}
_renderDevice() {
if (!this._that._points.length) return;
// if (!this._that.isPlaying) return;
// const lastData = this._that._getTimelyData(this._that.elapsedMs - 100 > 0 ? this._that.elapsedMs - 100 : 0);
const nextData = this._that._getTimelyData(this._that.elapsedMs);
// const { deep, breadth, seeding, flow } = nextData;
this._that.timelyData = { ...nextData };
const { lng, lat, yaw } = nextData;
this._that._map.translateMarker({
markerId,
destination: {
longitude: lng,
latitude: lat,
},
autoRotate: false,
duration: 1,
rotate: yaw || 0,
moveWithRotate: true,
animationEnd: () => {
// this._that.timelyData = { ...nextData };
// this._that.currentIndex += 1;
// if (this._that.isPlaying) {
// this._that.elapsedMs += (duration * this._that.speedRate);
// if (this._that.elapsedMs >= this._that.totalTime) {
// this._that.handleStop();
// return;
// }
// this._that._renderDevice();
// }
}
});
}
_ticker = timestamp => {
this._that.elapsedMs += Math.round((timestamp - this._that._lastFrameAt) * this._that.speedRate);
this._that._lastFrameAt = timestamp;
if (this._that.elapsedMs > this._that.totalTime) {
return;
}
// 在期望的间隔内_renderDevice,而不是每个tick都_renderDevice(目的:降低render频率,提高显示性能)
const now = Date.now();
const timeDiff = now - this._that._lastFrameTimestamp;
if (timeDiff > this._that._fpsInterval) {
this._that._lastFrameTimestamp = now - (timeDiff % this._that._fpsInterval); // 矫正时间戳
this._that._renderDevice();
}
this._that._timer = requestAnimationFrame(this._that._ticker);
};
// 开始播放巡航动画、恢复播放巡航动画
handlePlay() {
requestAnimationFrame(ms => {
this._that._lastFrameAt = ms;
});
this._that.isPlaying = true;
this._that.isPaused = false;
this._that.isStopped = false;
requestAnimationFrame(this._that._ticker);
}
// 暂停播放巡航动画
handlePause() {
this._that.isPlaying = false;
this._that.isPaused = true;
this._that.isStopped = false;
cancelAnimationFrame(this._that._timer);
}
// 停止播放巡航动画
handleStop() {
this._that.isPlaying = false;
this._that.isPaused = false;
this._that.isStopped = true;
this._that._lastFrameAt = 0;
this._that.elapsedMs = 0;
cancelAnimationFrame(this._that._timer);
this._that._renderDevice();
this._that.timelyData = {};
}
clear() {
if (!this._that._map) return;
this._that.handleStop();
this._that._clearDevice();
this._that._reset();
this._that._dataSource = {};
this._that.timelyData = {};
}
destroy() {
this._that.clear();
this._that._timer = null;
this._that._map = null;
}
}
export default new DeviceCruise();

92
src/core/helper/markHelper.js

@ -0,0 +1,92 @@
import { Point, PointerEvent } from "@leafer-ui/miniapp";
import CLASSIFY_MAP from '../../../config/classifyMap';
// import { ref } from "vue";
let rapes;
let handles;
let beaks;
function init(data = { rapes: [], handles: [], beaks: [] }) {
rapes = data.rapes;
handles = data.handles;
beaks = data.beaks;
}
function allMarkOnListen(event = PointerEvent.TAP, ftn = () => {}) {
rapes.forEach(rapeItem => {
rapeItem.on(event, ftn);
});
beaks.forEach(beakItem => {
beakItem.on(event, ftn);
});
handles.forEach(handleItem => {
handleItem.on(event, ftn);
});
}
function allMarkOffListen(event = PointerEvent.TAP, ftn = () => {}) {
rapes.forEach(rapeItem => {
rapeItem.off(event, ftn);
});
beaks.forEach(beakItem => {
beakItem.off(event, ftn);
});
handles.forEach(handleItem => {
handleItem.off(event, ftn);
});
}
function setRapesVisible(val = true) {
rapes.forEach(rapeItem => {
rapeItem.set({
visible: val,
});
});
}
function setBeaksVisible(val = true) {
beaks.forEach(beakItem => {
beakItem.set({
visible: val,
});
});
}
function setHandlesVisible(val = true) {
handles.forEach(handleItem => {
handleItem.set({
visible: val,
});
});
}
function InfoInduction(e, ratio = 0) {
const { extra: { classify, points } } = e?.current || {};
const pointStart = points[0];
const pointEnd = points[2];
const point = new Point()
const pixel = point.set(pointStart[0], pointStart[1]).getDistance({ x: pointEnd[0], y: pointEnd[1] }); // 100
const { [classify]: { zh: name } } = CLASSIFY_MAP;
const distance = ratio ? +((pixel / ratio).toFixed(1)) : 0;
//
// console.log('pixel', pixel);
// console.log('distance', distance);
// console.log('name', name);
//
return {
name,
pixel,
distance,
}
}
export default {
init,
allMarkOnListen,
allMarkOffListen,
setRapesVisible,
setBeaksVisible,
setHandlesVisible,
InfoInduction,
}

281
src/core/helper/pinHelper.js

@ -0,0 +1,281 @@
import { DragEvent, Path, Point, PointerEvent, Text, ZoomEvent } from "@leafer-ui/miniapp";
import { computed, ref } from "vue";
const pinOption = {
// scale: 0.03,
around: 'bottom',
path: "M464.1 461.4v-240c0-5-1.6-9.1-4.8-12.3-3.2-3.2-7.3-4.8-12.3-4.8-5 0-9.1 1.6-12.3 4.8-3.2 3.2-4.8 7.3-4.8 12.3v240c0 5 1.6 9.1 4.8 12.3 3.2 3.2 7.3 4.8 12.3 4.8 5 0 9.1-1.6 12.3-4.8 3.2-3.1 4.8-7.2 4.8-12.3z m360 188.6c0 9.3-3.4 17.3-10.1 24.1-6.8 6.8-14.8 10.2-24.1 10.1H560l-27.3 258.7c-0.7 4.3-2.6 8-5.6 11-3 3-6.7 4.5-11 4.5h-0.5c-9.6 0-15.4-4.8-17.1-14.5l-40.7-259.8H241.3c-9.3 0-17.3-3.4-24.1-10.1-6.8-6.8-10.2-14.8-10.1-24.1 0-43.9 14-83.5 42-118.6 28-35.1 59.7-52.7 95.1-52.8V204.2c-18.6 0-34.6-6.8-48.2-20.4-13.6-13.6-20.4-29.6-20.4-48.2s6.8-34.6 20.4-48.2C309.6 73.8 325.6 67 344.2 67H687c18.6 0 34.6 6.8 48.2 20.4 13.6 13.6 20.4 29.6 20.4 48.2s-6.8 34.6-20.4 48.2c-13.6 13.6-29.6 20.4-48.2 20.4v274.3c35.3 0 67 17.6 95.1 52.8 28 35.2 42 74.7 42 118.7z m0 0",
fill: 'rgba(255,108,0,0.9)',
};
const lineOption = {
stroke: '#00ffff',
strokeWidth: 10,
}
const textOption = {
around: 'center',
fontSize: 100,
fill: 'rgb(175,0,0)',
text: `点击输入实际长度`,
fontWeight: 'bold',
padding: [0, 0, 200]
}
let pinS;
let line;
let pinE;
let text;
let group;
const length = ref('0');
const ratio = computed(() => {
if (!pinE) return 0;
if (length.value === '0') return 0;
const pixel = new Point().set(pinS.x, pinS.y).getDistance({ x: pinE.x, y: pinE.y });
return +((pixel / +length.value).toFixed(1));
});
function installGroup(g = {}) {
group = g;
}
function creatPinS(option = {}) {
if (pinS) return;
pinS = new Path({ ...pinOption, ...option });
group.add(pinS);
}
function creatLine(option = {}) {
if (pinE) return;
console.log('optionS', option);
line = new Path({
path: [1, option.sx, option.sy, 2, option.ex, option.ey],
...lineOption,
})
group.add(line);
}
function moveLine(option = {}) {
if (pinE) return;
line.set({
path: [1, option.sx, option.sy, 2, option.ex, option.ey],
})
}
function creatPinE(option = {}) {
if (pinE) return;
pinE = new Path({ ...pinOption, ...option, className: 'pinE' });
group.add(pinE);
//
pinS.set({ draggable: true });
pinE.set({ draggable: true });
_creatComplete();
}
const eventPool = {};
function textTapBack(ftn = () => {}) {
eventPool.textTapBack = ftn;
if (text) {
text.on(PointerEvent.TAP, eventPool?.textTapBack);
}
}
function creatText(option = {}) {
if (text) return;
const textPoint = new Point().set(pinS.x, pinS.y).getCenter({ x: pinE.x, y: pinE.y });
const rotation = new Point().set(pinS.x, pinS.y).getRotation({ x: pinS.x, y: pinS.y }, { x: pinE.x, y: pinE.y });
// scale: 0.1 / e.current.scale,
text = new Text({
x: textPoint.x,
y: textPoint.y,
rotation: rotation,
...option,
...textOption,
});
group.add(text);
//
text.on(PointerEvent.TAP, (eventPool?.textTapBack || function () {}));
}
function _pinSDragCallBack(e) {
const point = e.getLocal();
pinS.set({ x: point.x, y: point.y });
line.set({ path: [1, pinS.x, pinS.y, 2, pinE.x, pinE.y] });
const textPoint = new Point().set(pinS.x, pinS.y).getCenter({ x: pinE.x, y: pinE.y });
const rotation = new Point().set(pinS.x, pinS.y).getRotation({ x: pinS.x, y: pinS.y }, { x: pinE.x, y: pinE.y });
const pixel = new Point().set(pinS.x, pinS.y).getDistance({ x: pinE.x, y: pinE.y });
// console.log('rotation', rotation, { x: pinS.x, y: pinS.y }, { x: pinE.x, y: pinE.y });
const l = ratio.value ? +((pixel / ratio.value).toFixed(1)) : 0;
text.set({
x: textPoint.x,
y: textPoint.y,
rotation,
text: l ? `${l}cm` : '点击输入实际长度',
});
length.value = `${l}`;
}
function _pinEDragCallBack(e) {
const point = e.getLocal();
pinE.set({ x: point.x, y: point.y });
line.set({ path: [1, pinS.x, pinS.y, 2, pinE.x, pinE.y] });
const textPoint = new Point().set(pinS.x, pinS.y).getCenter({ x: pinE.x, y: pinE.y });
const rotation = new Point().set(pinS.x, pinS.y).getRotation({ x: pinS.x, y: pinS.y }, { x: pinE.x, y: pinE.y });
const pixel = new Point().set(pinS.x, pinS.y).getDistance({ x: pinE.x, y: pinE.y });
// console.log('rotation', rotation, { x: pinS.x, y: pinS.y }, { x: pinE.x, y: pinE.y });
const l = ratio.value ? +((pixel / ratio.value).toFixed(1)) : 0;
text.set({
x: textPoint.x,
y: textPoint.y,
rotation,
text: l ? `${l}cm` : '点击输入实际长度',
});
length.value = `${l}`;
}
function setLength(val) {
length.value = `${val}`;
if (text) {
text.set({
text: `${val}cm`,
})
}
}
function _groupZoom(e) {
if (pinS) {
pinS.set({
scale: 0.03 / e.current.scale,
})
}
if (pinE) {
pinE.set({
scale: 0.03 / e.current.scale,
})
}
if (text) {
text.set({
scale: 0.1 / e.current.scale,
})
}
if (line) {
line.set({
strokeWidth: 5 / group.scale,
});
}
}
function _creatComplete() {
pinS.on(DragEvent.DRAG, _pinSDragCallBack);
pinE.on(DragEvent.DRAG, _pinEDragCallBack);
group.on(ZoomEvent.ZOOM, _groupZoom)
}
function _offDragEvent() {
pinS.off(DragEvent.DRAG, _pinSDragCallBack);
pinE.off(DragEvent.DRAG, _pinEDragCallBack);
}
function _onDragEvent() {
pinS.on(DragEvent.DRAG, _pinSDragCallBack);
pinE.on(DragEvent.DRAG, _pinEDragCallBack);
}
function pinDragAble(val = false) {
if (pinS) {
pinS.set({ draggable: val });
}
if (pinE) {
pinE.set({ draggable: val });
}
if (pinS && pinE) {
if (val) _onDragEvent();
if (!val) _offDragEvent();
}
console.log('val', val, pinS, pinE);
}
function offTextTapBack() {
if (text) {
text.off(PointerEvent.TAP, (eventPool?.textTapBack || function () {}));
}
}
function getScaleBarInfo() {
return {
points: [[pinS.x, pinS.y], [pinE.x, pinE.y]],
scale: ratio.value,
size: length.value,
}
}
function showScale(option = {}, groupScale = 1) {
const { points, scale, size } = option;
const pinSxy = { x: points[0][0], y: points[0][1] };
const pinExy = { x: points[1][0], y: points[1][1] };
pinS = new Path({ ...pinOption, x: pinSxy.x, y: pinSxy.y, scale: 0.03 / groupScale, draggable: false });
pinE = new Path({ ...pinOption, x: pinExy.x, y: pinExy.y, scale: 0.03 / groupScale, draggable: false, className: 'pinE' });
line = new Path({
path: [1, pinSxy.x, pinSxy.y, 2, pinExy.x, pinExy.y],
...lineOption,
strokeWidth: 5 / groupScale,
});
const textPoint = new Point().set(pinSxy.x, pinSxy.y).getCenter({ x: pinExy.x, y: pinExy.y });
const rotation = new Point().set(pinSxy.x, pinSxy.y).getRotation({ x: pinSxy.x, y: pinSxy.y }, { x: pinExy.x, y: pinExy.y });
// scale: 0.1 / e.current.scale,
text = new Text({
x: textPoint.x,
y: textPoint.y,
rotation: rotation,
// ...option,
...textOption,
scale: 0.1 / groupScale,
text: `${size}cm`,
});
//
text.on(PointerEvent.TAP, (eventPool?.textTapBack || function () {}));
group.add(pinS);
group.add(pinE);
group.add(line);
group.add(text);
length.value = `${size}`;
group.on(ZoomEvent.ZOOM, _groupZoom)
}
function clearPin() {
if (pinE) {
_offDragEvent();
group.off(ZoomEvent.ZOOM, _groupZoom);
group.remove(pinS, true);
group.remove(pinE, true);
group.remove(line, true);
group.remove(text, true);
pinS = undefined;
pinE = undefined;
line = undefined;
text = undefined;
length.value = '0';
}
}
export default {
installGroup,
creatPinS,
creatLine,
moveLine,
creatPinE,
creatText,
textTapBack,
length,
ratio,
setLength,
pinDragAble,
offTextTapBack,
getScaleBarInfo,
showScale,
clearPin,
};

273
src/core/helper/sceneHelper.js

@ -0,0 +1,273 @@
import { Group, Image, ImageEvent, Leafer, Line, Path, Point, PointerEvent } from "@leafer-ui/miniapp";
import CLASSIFY_MAP from '../../../config/classifyMap';
import MarkHelper from './markHelper';
import PinHelper from './pinHelper';
import Taro from "@tarojs/taro";
// import { ref } from "vue";
let leafer;
let group;
let image;
function init(option = {}) {
leafer = new Leafer({ ...option });
return leafer;
}
function setGroup(option = {}) {
group = new Group({ ...option });
leafer.add(group);
leafer.zoomLayer = group;
PinHelper.installGroup(group);
return group;
}
let scale = 0.1;
function groupSetImage(option = {}, callBackFtn = () => {}) {
image = new Image({ ...option });
group.add(image);
image.once(ImageEvent.LOADED, function (e) {
const res = Taro.getSystemInfoSync();
const scale = res.windowWidth / e.current.width;
groupFill(scale);
callBackFtn(scale);
})
return image;
}
function groupSetMark(data = [], option = {}) {
const rapes = [];
const handles = [];
const beaks = [];
data.forEach(({ classify, name, points }) => {
const { [classify]: { color: stroke, strokeWidth } } = CLASSIFY_MAP;
const point1 = { x: points[0][0], y: points[0][1] };
const point2 = { x: points[1][0], y: points[1][1] };
const point3 = { x: points[2][0], y: points[2][1] };
const point4 = { x: points[3][0], y: points[3][1] };
const item = new Path({
className: name,
extra: { classify, name, points },
path: [1, point1.x, point1.y, 2, point2.x, point2.y, 2, point3.x, point3.y, 2, point4.x, point4.y, 11],
stroke,
strokeWidth,
fill: 'rgba(255,255,255,0)'
})
group.add(item);
// 分类
const { [classify]: list } = { 0: rapes, 1: beaks, 2: handles };
list.push(item);
});
MarkHelper.init({ rapes, handles, beaks });
}
function getMarkList() {
const rapesList = group.find('.rape');
const handlesList = group.find('.handle');
const beaksList = group.find('.beak');
return { rapesList, handlesList, beaksList };
}
function setMarkTapListen(ftn = () => {}) {
MarkHelper.allMarkOnListen(PointerEvent.TAP, ftn);
}
function offMarkTapListen(ftn = () => {}) {
MarkHelper.allMarkOffListen(PointerEvent.TAP, ftn);
}
function groupFill(scale = 0.1) {
group.scaleOf('left', scale);
group.set({
y: 150
});
}
function groupOnDownListen(ftn = () => {}) {
group.on(PointerEvent.DOWN, ftn);
}
function groupOffDownListen(ftn = () => {}) {
group.off(PointerEvent.DOWN, ftn);
}
function groupRemoveChild(child = {}) {
group.remove(child);
}
function groupOnMoveListen(ftn = () => {}) {
group.on(PointerEvent.MOVE, ftn);
}
function groupOnUpListen(ftn = () => {}) {
group.on(PointerEvent.UP, ftn);
}
let sx;
let sy;
function _moveLineEPoint(e) {
const point = { x: e.x, y: e.y };
image.worldToLocal(point);
const lineOption = { sx, sy, ex: point.x, ey: point.y };
PinHelper.moveLine({ ...lineOption });
}
function _creatPinSPoint(e) {
const point = { x: e.x, y: e.y };
image.worldToLocal(point);
sx = point.x;
sy = point.y;
PinHelper.creatPinS({ ...point, scale: 0.03 / e.current.scale, });
const lineOption = { sx: point.x, sy: point.y, ex: point.x, ey: point.y };
PinHelper.creatLine({ ...lineOption });
}
// let pinTextTapCallBack = () => {};
function _endPinEPoint(e) {
const point = { x: e.x, y: e.y };
image.worldToLocal(point);
const lineOption = { sx, sy, ex: point.x, ey: point.y };
PinHelper.moveLine({ ...lineOption });
PinHelper.creatPinE({ ...point, scale: 0.03 / e.current.scale });
PinHelper.creatText({ scale: 0.1 / e.current.scale });
group.off(PointerEvent.DOWN, _creatPinSPoint);
group.off(PointerEvent.MOVE, _moveLineEPoint);
group.off(PointerEvent.UP, _endPinEPoint);
}
function groupCreatPin() {
group.on(PointerEvent.DOWN, _creatPinSPoint);
group.on(PointerEvent.MOVE, _moveLineEPoint);
group.on(PointerEvent.UP, _endPinEPoint);
}
function calculateNewPoints(A, B, d) {
// 计算方向向量
let directionVector = { x: B.x - A.x, y: B.y - A.y };
// 计算方向向量的长度
let length = Math.hypot(directionVector.x, directionVector.y);
// 归一化方向向量
let unitDirectionVector = { x: directionVector.x / length, y: directionVector.y / length };
// 计算延长线段的向量
// let extensionVector = { x: unitDirectionVector.x * d, y: unitDirectionVector.y * d };
// 计算延长后的端点坐标
// let newPointA1 = { x: A.x - extensionVector.x, y: A.y - extensionVector.y };
// let newPointA2 = { x: A.x + extensionVector.x, y: A.y + extensionVector.y };
// let newPointB1 = { x: B.x - extensionVector.x, y: B.y - extensionVector.y };
// let newPointB2 = { x: B.x + extensionVector.x, y: B.y + extensionVector.y };
// 计算垂直于AB的单位向量
let perpendicularUnitVector = { x: -unitDirectionVector.y, y: unitDirectionVector.x };
// 计算顺时针和逆时针旋转90度后的新端点坐标
let newPointA3 = { x: A.x + perpendicularUnitVector.x * d, y: A.y + perpendicularUnitVector.y * d };
let newPointA4 = { x: A.x - perpendicularUnitVector.x * d, y: A.y - perpendicularUnitVector.y * d };
let newPointB3 = { x: B.x + perpendicularUnitVector.x * d, y: B.y + perpendicularUnitVector.y * d };
let newPointB4 = { x: B.x - perpendicularUnitVector.x * d, y: B.y - perpendicularUnitVector.y * d };
return {
// newPointsA: [newPointA1, newPointA2, newPointA3, newPointA4],
// newPointsB: [newPointB1, newPointB2, newPointB3, newPointB4],
point: [newPointA3, newPointB3, newPointB4, newPointA4],
};
}
function groupAddMark({ type = '', tapCallBack = () => {}, height = 10 }) {
group.set({ draggable: false });
let line;
let sx;
let sy;
function _addMarkStart(e) {
const point = e.getInner();
sx = point.x;
sy = point.y;
line = new Line({
points: [sx, sy, sx, sy],
stroke: '#00ffff',
strokeWidth: 10,
})
group.add(line);
}
function _moveMark(e) {
const point = e.getInner();
line.set({
points: [sx, sy, point.x, point.y],
})
}
function _doneDrawMark(e) {
const point = e.getInner();
line.set({
points: [sx, sy, point.x, point.y],
});
// 示例使用
// let A = { x: 1, y: 2 };
// let B = { x: 4, y: 6 };
// let d = 3;
let result = calculateNewPoints({ x: sx, y: sy }, { x: point.x, y: point.y }, height);
const temp = result.point;
const point1 = { x: temp[0].x, y: temp[0].y };
const point2 = { x: temp[1].x, y: temp[1].y };
const point3 = { x: temp[2].x, y: temp[2].y };
const point4 = { x: temp[3].x, y: temp[3].y };
const points = [[point1.x, point1.y], [point2.x, point2.y], [point3.x, point3.y], [point4.x, point4.y],]
const typeItem = Object.values(CLASSIFY_MAP).find(({ name }) => name === type);
const item = new Path({
className: typeItem.name,
extra: { classify: typeItem.classify, name: typeItem.name, points },
path: [1, point1.x, point1.y, 2, point2.x, point2.y, 2, point3.x, point3.y, 2, point4.x, point4.y, 11],
stroke: typeItem.color,
strokeWidth: typeItem.strokeWidth,
fill: 'rgba(255,255,255,0)'
})
group.add(item);
item.on(PointerEvent.TAP, tapCallBack);
group.off(PointerEvent.DOWN, _addMarkStart);
group.off(PointerEvent.MOVE, _moveMark)
group.off(PointerEvent.UP, _doneDrawMark)
callBackFtn();
const { rapesList, handlesList, beaksList } = getMarkList()
MarkHelper.init({ rapes: rapesList, handles: handlesList, beaks: beaksList });
group.set({ draggable: true });
group.remove(line);
}
let callBackFtn = () => {};
function done(ftn = () => {}) {
callBackFtn = ftn;
}
group.on(PointerEvent.DOWN, _addMarkStart);
group.on(PointerEvent.MOVE, _moveMark)
group.on(PointerEvent.UP, _doneDrawMark)
return {
done,
}
}
export default {
// leafer,
init,
setGroup,
groupSetImage,
groupSetMark,
setMarkTapListen,
offMarkTapListen,
groupFill,
groupOnDownListen,
groupOffDownListen,
groupRemoveChild,
getMarkList,
groupCreatPin,
groupAddMark,
}

461
src/core/useDeviceCruise.js

@ -0,0 +1,461 @@
/**
* 设备按轨迹巡航
*/
import {computed, reactive, ref} from 'vue';
// import { interpolate } from 'popmotion';
// import * as turf from '@turf/turf';
// import deviceIcon from '../assets/deviceIcon.png';
import deviceIcon from '../assets/droneImg.png';
// import { GPS2GCJ } from '../utils/helpers';
// import * as geo from "../utils/geo";
const markerId = 1e6;
const markerOption = {
id: markerId,
iconPath: deviceIcon,
width: 18,
height: 18,
anchor: { x: 0.5, y: 0.5 },
}
export const useDeviceCruise = () => {
const markers = ref([]);
const points = ref([]);
const isPlaying = ref(false);
const isPaused = ref(true);
const rotate = ref(0);
const elapsedMs = ref(0);
const timelyData = ref({});
const datumTime = computed(() => {
const [{ timestamp: startTs } = {}] = points.value || [];
return points.value.map(({ timestamp }) => timestamp - startTs);
});
const totalTime = computed(() => datumTime.value[datumTime.value.length - 1] || 0);
function setCurrentTime(ms) {
if (ms < 0 || ms > totalTime.value) return;
elapsedMs.value = ms;
const index = datumTime.value.findIndex(item => item >= ms);
// this._that.currentIndex = index;
const point = points.value[index];
const { lng, lat, yaw } = point;
timelyData.value = { ...point };
markers.value = [{
...markerOption,
latitude: lat,
longitude: lng,
rotate: yaw + rotate.value,
}];
}
function initRenderDevice() {
const [point] = points.value || [];
if (!point) {
return;
}
const { lng, lat, yaw } = point;
timelyData.value = { ...point };
// console.log(yaw, rotate.value, yaw + rotate.value);
markers.value = [{
...markerOption,
latitude: lat,
longitude: lng,
rotate: yaw + rotate.value,
}];
}
let timer
function autoRenderDevice() {
if (isPaused.value) return;
const index = datumTime.value.findIndex(item => item >= elapsedMs.value);
// this._that.currentIndex = index;
if (index === -1) {
isPlaying.value = false;
isPaused.value = true;
if (timer) {
clearTimeout(timer);
}
return;
}
const point = points.value[index];
timelyData.value = { ...point };
const { lng, lat, yaw } = point;
markers.value = [{
...markerOption,
latitude: lat,
longitude: lng,
rotate: yaw + rotate.value,
}];
// console.log('yaw', yaw);
elapsedMs.value += 100;
timer = setTimeout(autoRenderDevice, 100);
}
function play() {
isPlaying.value = true;
isPaused.value = false;
if (timer) {
clearTimeout(timer);
}
autoRenderDevice();
}
function pause() {
isPlaying.value = false;
isPaused.value = true;
if (timer) {
clearTimeout(timer);
}
}
return {
markers,
points,
isPlaying,
isPaused,
rotate,
elapsedMs,
timelyData,
datumTime,
totalTime,
setCurrentTime,
initRenderDevice,
autoRenderDevice,
play,
pause,
}
}
// class DeviceCruise {
//
// _that = null;
//
// // 地图实例
// _map = null;
//
// // 单条轨迹数据
// _dataSource = {};
//
// // 巡航速率
// speedRate = 1;
//
// // 动画计时器
// _timer = null;
//
// // 上一帧时间点
// _lastFrameAt = 0;
//
// // 累计播放时长(毫秒数)
// elapsedMs = 0;
//
// // 每帧期望间隔(毫秒数,实际间隔取决于浏览器fps)
// _fpsInterval = 1000 / 50;
//
// // 上一帧时间戳
// _lastFrameTimestamp = 0;
//
// // 巡航到的时间点数据
// timelyData = {};
//
// isPlaying = false;
//
// isPaused = false;
//
// isStopped = true;
//
// // currentIndex = 0;
//
// // 是否准备完毕
// get ready() {
// return Object.keys(this._that._dataSource).length > 0;
// }
//
// get _points() {
// const { points } = this._that._dataSource;
// return points || [];
// }
//
// // 基准时间点(从0开始的毫秒数)
// get _datumTime() {
// const [{ timestamp: startTs } = {}] = this._that._points || [];
// return this._that._points.map(({ timestamp }) => timestamp - startTs);
// }
//
// // 轨迹总时间(毫秒数)
// get totalTime() {
// // console.log('this._that._datumTime', this._that._datumTime);
// return this._that._datumTime[this._that._datumTime.length - 1] || 0;
// }
//
// // 是否已经开始播放了
// get isStarted() {
// return this._that.isPlaying || this._that.isPaused;
// }
//
// constructor() {
// this._that = reactive(this);
// return this._that;
// }
//
// // 设置地图实例
// setMap(mapInstance) {
// this._that._map = mapInstance;
// }
//
// mapRotate = 0;
// // 载入轨迹数据(point中必须包含lng, lat, timestamp, yaw)
// loadTrack({ id, points, ...others }, rotate = 0) {
// // if (!this._that._that._map) {
// // throw new Error('请先设置地图实例');
// // }
// this._that.mapRotate = rotate;
// this._that._dataSource = {
// id,
// points: (points || []).map(item => {
// const [lng, lat] = GPS2GCJ([item.lng, item.lat]);
// return {
// ...item,
// lng,
// lat,
// yaw: +geo.radToDeg(item.yaw || 0).toFixed(1),
// };
// }),
// ...others,
// };
// this._that._reset();
// this._that._initDevice();
// }
//
// // 设置速率
// setSpeedRate(val) {
// this._that.speedRate = val;
// }
//
// _setting = false;
// // 设置当前巡航时间点
// setCurrentTime(ms) {
// if (ms < 0 || ms > this._that.totalTime) return;
// // if (this._that.isPlaying) {
// // this._that.isPlaying = false;
// // this._that.isPaused = true;
// // }
//
// // if (this._that._setting) return;
// // this._that._setting = true;
// this._that.elapsedMs = ms;
// const index = this._that._datumTime.findIndex(item => item >= ms);
// this._that.currentIndex = index;
// const point = this._that._points[index];
// const { lng, lat, yaw } = point;
// // this._that._map.removeMarkers({
// // markerIds: [markerId],
// // success: () => {
// // // this._that._renderDevice();
// // this._that._map.addMarkers({
// // markers: [{
// // ...this._that._markerOptins,
// // latitude: lat,
// // longitude: lng,
// // rotate: yaw + this._that.mapRotate,
// // }],
// // success: () => {
// // this._that._setting = false;
// // }
// // });
// // }
// // });
// this._that.markers.value = [{
// ...this._that._markerOptins,
// latitude: lat,
// longitude: lng,
// rotate: yaw + this._that.mapRotate,
// }];
// }
//
// // 获取指定毫秒处的数据值
// _getTimelyData(ms = 0) {
// // const genTimelyData = interpolate(this._that._datumTime, this._that._points);
// const index = this._that._datumTime.findIndex(item => item >= ms);
// // console.log('genTimelyData', genTimelyData(ms));
// // if (index === -1) {
// // return {};
// // }
//
// // return genTimelyData(ms);
// return this._that._points[index] || {};
// }
//
// _reset() {
// this._that.isPlaying = false;
// this._that.isPaused = false;
// this._that.isStopped = true;
// this._that.speedRate = 1;
// this._that._lastFrameAt = 0;
// this._that.elapsedMs = 0;
// this._that._lastFrameTimestamp = 0;
// this._that.markers.value = [];
// }
//
// markers = ref([]);
// _initDevice() {
// const [point] = this._that._points;
// if (!point) {
// return;
// }
//
// const { lng, lat, yaw } = point;
// this._that.markers.value = [{
// ...this._that._markerOptins,
// latitude: lat,
// longitude: lng,
// rotate: yaw + this._that.mapRotate,
// }];
// // this._that._map.addMarkers({
// // markers: [{
// // ...this._that._markerOptins,
// // latitude: lat,
// // longitude: lng,
// // rotate: yaw + this._that.mapRotate,
// // }]
// // })
// }
//
// _clearDevice() {
// // 清除timelyData即可
// this._that.timelyData = {};
// // this._that._map.removeMarkers({
// // markerIds: [markerId]
// // });
// this._that.markers.value = [];
// }
//
// _renderDevice() {
// if (!this._that._points.length) return;
// // if (!this._that.isPlaying) return;
//
// // const lastData = this._that._getTimelyData(this._that.elapsedMs - 100 > 0 ? this._that.elapsedMs - 100 : 0);
// const nextData = this._that._getTimelyData(this._that.elapsedMs);
//
// // const { deep, breadth, seeding, flow } = nextData;
// this._that.timelyData = { ...nextData };
// const { lng, lat, yaw } = nextData;
//
// // this._that._map.translateMarker({
// // markerId,
// // destination: {
// // longitude: lng,
// // latitude: lat,
// // },
// // autoRotate: false,
// // duration: 1,
// // rotate: yaw + this._that.mapRotate,
// // moveWithRotate: true,
// // animationEnd: () => {
// // // this._that.timelyData = { ...nextData };
// // // this._that.currentIndex += 1;
// // // if (this._that.isPlaying) {
// // // this._that.elapsedMs += (duration * this._that.speedRate);
// // // if (this._that.elapsedMs >= this._that.totalTime) {
// // // this._that.handleStop();
// // // return;
// // // }
// // // this._that._renderDevice();
// // // }
// // }
// // });
//
// // this._that._map.removeMarkers({
// // markerIds: [markerId],
// // success: () => {
// // // this._that._renderDevice();
// // this._that._map.addMarkers({
// // markers: [{
// // ...this._that._markerOptins,
// // latitude: lat,
// // longitude: lng,
// // rotate: yaw + this._that.mapRotate,
// // }],
// // success: () => {
// // // this._that._setting = false;
// // }
// // });
// // }
// // });
// this._that.markers.value = [{
// ...this._that._markerOptins,
// latitude: lat,
// longitude: lng,
// rotate: yaw + this._that.mapRotate,
// }];
// }
//
// _ticker = timestamp => {
// this._that.elapsedMs += Math.round((timestamp - this._that._lastFrameAt) * this._that.speedRate);
// this._that._lastFrameAt = timestamp;
// if (this._that.elapsedMs > this._that.totalTime) {
// return;
// }
//
// // 在期望的间隔内_renderDevice,而不是每个tick都_renderDevice(目的:降低render频率,提高显示性能)
// const now = Date.now();
// const timeDiff = now - this._that._lastFrameTimestamp;
// if (timeDiff > this._that._fpsInterval) {
// this._that._lastFrameTimestamp = now - (timeDiff % this._that._fpsInterval); // 矫正时间戳
// this._that._renderDevice();
// }
//
// this._that._timer = requestAnimationFrame(this._that._ticker);
// };
//
// // 开始播放巡航动画、恢复播放巡航动画
// handlePlay() {
// requestAnimationFrame(ms => {
// this._that._lastFrameAt = ms;
// });
// this._that.isPlaying = true;
// this._that.isPaused = false;
// this._that.isStopped = false;
// requestAnimationFrame(this._that._ticker);
// }
//
// // 暂停播放巡航动画
// handlePause() {
// this._that.isPlaying = false;
// this._that.isPaused = true;
// this._that.isStopped = false;
// cancelAnimationFrame(this._that._timer);
// }
//
// // 停止播放巡航动画
// handleStop() {
// this._that.isPlaying = false;
// this._that.isPaused = false;
// this._that.isStopped = true;
// this._that._lastFrameAt = 0;
// this._that.elapsedMs = 0;
// cancelAnimationFrame(this._that._timer);
// this._that._renderDevice();
// this._that.timelyData = {};
// }
//
// clear() {
// if (!this._that._map) return;
// this._that.handleStop();
// this._that._clearDevice();
// this._that._reset();
// this._that._dataSource = {};
// this._that.timelyData = {};
// }
//
// destroy() {
// this._that.clear();
// this._that._timer = null;
// this._that._map = null;
// }
// }
// export default new DeviceCruise();

9
src/pages/airfield/index.config.js

@ -0,0 +1,9 @@
export default definePageConfig({
navigationBarTitleText: '场地管理',
// disableSwipeBack: true,
// enablePullDownRefresh: true,
// backgroundTextStyle: 'dark',
// navigationStyle: 'custom',
// enablePullDownRefresh: false,
// disableScroll: false,
})

413
src/pages/airfield/index.vue

@ -0,0 +1,413 @@
<script setup>
import {ref, computed, reactive} from 'vue';
import Taro from '@tarojs/taro';
import TabBar from '../../components/TabBar.vue'
import {useAirFieldsStore, useStandardStore} from "../../stores";
import { storeToRefs } from 'pinia';
// import { useSupervisionStore } from '../../stores';
Taro.hideHomeButton();
const { deleteAirField, getAirfieldList, getDroneList, airfieldBindClass, getClassList, getLicenseGradesList } = useAirFieldsStore();
const { droneList, airfieldList, classList, licenseGradesList } = storeToRefs(useAirFieldsStore());
const { getEnvList } = useStandardStore();
const { envList } = storeToRefs(useStandardStore());
getDroneList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
getClassList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
// getEnvList().catch(({ msg }) => {
// if (msg) openToast('warn', msg);
// })
// getLicenseGradesList().catch(({ msg }) => {
// if (msg) openToast('warn', msg);
// })
const droneColumns = computed(() => {
return droneList.value.map(item => ({
text: item.name,
value: item.sn,
}))
});
const classColumns = computed(() => {
// return [
// {
// text: 'aaaaa',
// value: '5',
// },
// ]
return classList.value.map(item => ({
text: item.name,
value: item.id,
}));
})
// API
// const { getFieldList } = useSupervisionStore();
// const { fieldList } = storeToRefs(useSupervisionStore());
Taro.useDidShow(() => {
// 使API
// getFieldList().catch(err => {
// Taro.showToast({
// title: err?.description || '',
// icon: 'none'
// });
// });
});
// function onNavTo(id) {
// Taro.navigateTo({
// url: `/pages/airfieldMap/index?id=${id}`,
// });
// }
getList();
function getList() {
getAirfieldList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
function onCreateField() {
if (!droneColumns.value.length) {
openToast('warn', '您名下没有无人机');
return;
}
showDronePicker.value = true;
}
const isDeleted = ref(false);
const currentFieldId = ref();
function handleDeleteConfirm() {
if (!currentFieldId.value) return;
deleteAirField(currentFieldId.value).then(() => {
openToast('success', '成功删除');
getList();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
function onHandleDelete(id) {
currentFieldId.value = id;
isDeleted.value = true;
}
const showDronePicker = ref(false);
function onConfirmDrone({ selectedOptions }) {
Taro.navigateTo({
url: `/pages/airfieldMap/index?droneSn=${selectedOptions[0].value}`,
}).then(() => {
showDronePicker.value = false;
});
}
function onHandleBind(id) {
if (!classColumns.value.length) {
openToast('warn', '您名下没有班级');
return;
}
currentFieldId.value = id;
showClassPicker.value = true;
}
const showClassPicker = ref(false);
function onConfirmClass({ selectedOptions }) {
console.log(selectedOptions);
airfieldBindClass({
classId: selectedOptions[0].value,
airfieldId: currentFieldId.value
}).then(() => {
openToast('success', '成功指定');
showClassPicker.value = false;
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
// title: '',
// bottom: '',
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
function onHandleEditor() {
// showDronePicker.value = true;
}
</script>
<template>
<div :class="s.root">
<div class="field-list">
<div class="field-card" v-for="item in airfieldList" :key="item.id">
<div class="field-image">
<img :src="item.imageUrl || 'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/tmp/2025/05/tmp_1746237402612144307.png'" alt="场地图片" />
<div class="status-badge" :class="{ 'failed': true }" v-if="item?.classDetailResp">
当前班级{{ item?.classDetailResp?.name || '-' }}
</div>
</div>
<div class="field-info">
<div class="field-name">
<span class="icon">📍</span>
{{ item.name }}
</div>
<div class="info-item">
<div class="item">
<span class="label">风速</span>
<span class="value">{{ item.envGradeName || '暂无' }}</span>
</div>
<div class="item">
<span class="label">执照</span>
<span class="value">{{ item.licenseGradeName || '暂无' }}</span>
</div>
</div>
<div class="info-item">
<span class="label">创建时间</span>
<span class="value">{{ item.createDate }}</span>
</div>
</div>
<div class="info-btn">
<div class="box delete" @click="onHandleDelete(item.id)">删除</div>
<div class="box detail" @click="onHandleEditor(item.id)" v-if="false">修改</div>
<div class="box bind" @click="onHandleBind(item.id)">指定班级</div>
</div>
</div>
<div class="create-field-card" @click="onCreateField">
<div>+</div>
</div>
</div>
</div>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
<nut-dialog
title="删除提示"
content="确定删除该场地吗?"
v-model:visible="isDeleted"
@ok="handleDeleteConfirm"
/>
<nut-popup v-model:visible="showDronePicker" position="bottom" :style="{ height: '40%' }">
<nut-picker :columns="droneColumns" title="打点无人机" @confirm="onConfirmDrone" @cancel="showDronePicker = false" />
</nut-popup>
<nut-popup v-model:visible="showClassPicker" position="bottom" :style="{ height: '40%' }">
<nut-picker :columns="classColumns" title="选择班级" @confirm="onConfirmClass" @cancel="showClassPicker = false" />
</nut-popup>
<TabBar />
</template>
<style lang="less" module="s">
.root {
min-height: 100vh;
background-color: #f2fbff;
padding: 16px;
box-sizing: border-box;
:global {
.field-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 0;
.field-card {
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.16);
}
.field-image {
width: 100%;
height: 160px;
overflow: hidden;
background-color: #f0f0f0;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 12px;
font-size: 20px;
font-weight: 500;
color: #fff;
background-color: rgba(0, 0, 0, 0.6);
&.in-progress {
background-color: #1890ff;
}
&.passed {
background-color: #52c41a;
}
&.failed {
background-color: #ff4d4f;
}
}
}
.field-info {
padding: 12px;
font-size: 20px;
.student-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.student-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.teacher-name {
font-size: 12px;
color: #666;
background-color: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
}
.field-name {
color: #333;
margin-bottom: 10px;
display: flex;
align-items: center;
background-color: #f8f8f8;
padding: 6px 8px;
border-radius: 6px;
.icon {
margin-right: 4px;
//font-size: 14px;
}
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 6px;
//font-size: 13px;
//line-height: 1.4;
&:last-child {
margin-bottom: 0;
}
.item {
flex: 1;
display: flex;
align-items: center;
}
.label {
color: #666;
margin-right: 8px;
flex-shrink: 0;
min-width: 70px;
}
.value {
color: #333;
flex: 1;
white-space: nowrap;
}
}
}
.info-btn {
display: flex;
font-size: 20px;
//align-items: center;
//justify-content: space-around;
.box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
}
.detail {
color: white;
background-color: rgba(0, 101, 255, 0.81);
}
.bind {
color: white;
background-color: rgba(0, 188, 60, 0.83);
}
.delete {
color: white;
background-color: rgba(255, 1, 1, 0.91);
}
}
}
.create-field-card {
min-height: 300px;
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #7f8c8d;
font-size: 40px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.16);
}
}
}
}
}
</style>

93
src/pages/airfieldMap/BottomSide.vue

@ -0,0 +1,93 @@
<script setup>
import { computed, ref } from 'vue';
import { IconFont } from "@nutui/icons-vue-taro";
import { falsyTo } from '../../utils/helpers';
// import deviceCruise from '../../core/useDeviceCruise';
// import deviceIcon from '../../../assets/deviceIcon.png'
const isVisible = ref(true);
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="uav4" :style="{ color: info?.droneOnLine ? 'snow' : 'red', 'font-size': '24px' }" />
<div class="value" v-if="info?.connectLoading">连接中...</div>
<div class="value" v-else :style="{ color: info?.droneOnLine ? 'snow' : 'red' }">{{ info?.droneOnLine ? '已连接飞机' : '飞机失联..' }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="battery" />
<div class="value">{{ falsyTo(info?.voltage, '-') }} v</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="measure" :style="{ transform: 'rotate(90deg)' }" />
<div class="value">{{ falsyTo(info?.height, '-') }} m</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="compass" :style="{ transform: 'rotate(45deg)' }"/>
<div class="value">{{ falsyTo(info?.yaw, '-') }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="satellite" />
<div class="value">{{ falsyTo(info?.satellite, '-') }} {{ falsyTo(info?.fixTypeLabel, '-')}}</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 200px;
left: 0;
//top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: snow;
font-size: 14px;
font-weight: bold;
//width: 100px;
//min-width: 100px;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-around;
gap: 10px;
padding: 10px;
//color: black;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.item {
display: flex;
align-items: center;
font-family: monospace;
//color: white;
.iconfont {
width: 10px;
height: 10px;
font-size: 16px;
//color: #4CAF50;
}
.value {
margin-left: 8px;
font-size: 12px;
}
}
}
}
</style>

222
src/pages/airfieldMap/LeftSide.vue

@ -0,0 +1,222 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
const isVisible = ref(true);
// const info = computed(() => ({}));
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
function onBack() {
console.log('aaaaaa');
}
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="box">
<div class="countdown">
<div class="title">倒计时</div>
<div class="value">{{ toFixed(info.height || 177, 1) }}</div>
</div>
</div>
<div class="box box2">
<div class="data-item">
<div class="text">切线速度</div>
<div class="icon-wrapper" v-if="false" >
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" />
</div>
<div class="value-wrapper">{{ toFixed(info?.speed || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text">航向偏差</div>
<div class="icon-wrapper" v-if="false" >
<IconFont font-class-name="iconfont" class-prefix="icon" name="hangxiang" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angle || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text">高度偏差</div>
<div class="icon-wrapper" v-if="false" >
<IconFont font-class-name="iconfont" class-prefix="icon" name="gaodu" />
</div>
<div class="value-wrapper">{{ toFixed(info?.vertical || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text">水平偏差</div>
<div class="icon-wrapper" v-if="false" >
<IconFont font-class-name="iconfont" class-prefix="icon" name="width" />
</div>
<div class="value-wrapper">{{ toFixed(info?.horizontal || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text">角速度</div>
<div class="icon-wrapper" v-if="false" >
<IconFont font-class-name="iconfont" class-prefix="icon" name="jiaodu" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angleSpeed || 0, 1) }}</div>
</div>
</div>
<div class="box box3">
<div class="data-item">
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" />
</div>
<div class="value-wrapper">{{ toFixed(info?.yaw || 0, 1) }}</div>
</div>
</div>
<div class="box box4" @click="onBack">
<span>返回</span>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
// padding: 8px;
color: white;
font-size: 11px;
width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
:global {
.canvas-bg {
width: 100%;
height: 80%;
position: absolute;
//top: 0;
//right: 0;
//left: 0;
//bottom: 20px;
}
.nav {
position: absolute;
right: -20px;
top: 6px;
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 0 4px 4px 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
color: #7FFF00;
border-bottom: 1px solid rgb(255, 255, 255);
// padding: 4px 8px;
.countdown {
display: flex;
align-items: center;
justify-content: center;
//justify-content: ;
.title {
}
.value {
font-size: 24px;
font-weight: bold;
text-align: center;
padding: 5px;
}
}
.data-item {
display: flex;
align-items: center;
justify-content: center;
// gap: 12px;
// padding: 5px;
.text {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
}
.icon-wrapper {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
}
.value-wrapper {
flex: 1;
font-size: 18px;
font-weight: 500;
text-align: left;
padding-left: 8px;
}
}
}
.box2 {
padding: 4px 8px;
}
.box3 {
flex: 1;
color: red;
padding: 4px 8px;
.icon-wrapper {
background: rgba(255, 0, 0, 0.1) !important;
}
}
.box4 {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
//height: 40px;
color: white;
//text-align: center;
border-bottom: none;
}
}
}
</style>

124
src/pages/airfieldMap/ResultModal.vue

@ -0,0 +1,124 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false
},
name: {
type: String,
default: '张三'
},
uavId: {
type: String,
default: 'UAV-001'
},
isPassed: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:show']);
const handleClose = () => {
emit('update:show', false);
};
</script>
<template>
<view v-show="show" :class="s.root">
<view class="modal-content">
<view class="close-btn" @tap="handleClose">×</view>
<view class="result-status" :class="{ 'pass': isPassed }">
{{ isPassed ? '通过' : '未通过' }}
</view>
<view class="info-item">
<text class="label">姓名</text>
<text class="value">{{ name }}</text>
</view>
<view class="info-item">
<text class="label">飞机编号</text>
<text class="value">{{ uavId }}</text>
</view>
</view>
</view>
</template>
<style lang="less" module="s">
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
:global{
.modal-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 12px;
min-width: 280px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
color: #666;
}
}
.result-status {
font-size: 20px;
font-weight: bold;
text-align: center;
margin-bottom: 16px;
color: #ff4d4f;
&.pass {
color: #52c41a;
}
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
.label {
color: #838383;
margin-right: 8px;
font-size: 14px;
}
.value {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
</style>

257
src/pages/airfieldMap/RightSide.vue

@ -0,0 +1,257 @@
<script setup>
import {computed, reactive, ref} from 'vue';
import { storeToRefs } from 'pinia';
import {useAirFieldsStore, useStandardStore} from "../../stores";
import Taro from "@tarojs/taro";
// import {useDroneMarker} from "./useDroneMarker";
const { getEnvList } = useStandardStore();
const { envList } = storeToRefs(useStandardStore());
const { getLicenseGradesList, createAirfield, resetFormData } = useAirFieldsStore();
const { licenseGradesList, formData } = storeToRefs(useAirFieldsStore());
getEnvList().then(() => {
if (envList.value.length) {
formData.value.envGradeId = envList.value[0]?.id;
}
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
const envGradeOptions = computed(() => {
return envList.value.map(item => ({
text: item.gradeName,
value: item.id,
}));
});
getLicenseGradesList().then(() => {
if (licenseGradesList.value.length) {
formData.value.licenseGradeId = licenseGradesList.value[0]?.id;
}
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
const licenseGradesOptions = computed(() => {
return licenseGradesList.value.map(item => ({
text: item.name,
value: item.id,
}));
});
const emit = defineEmits(['create'])
function createCircle(index) {
emit('create', index);
}
function onCreateField() {
if (!formData.value.name) {
openToast('warn', '请填写场地名称');
return;
}
if (!formData.value.circle1Lat || !formData.value.circle1Lng) {
openToast('warn', '请先给圆1打点');
return;
}
if (!formData.value.circle2Lat || !formData.value.circle2Lng) {
openToast('warn', '请先给圆2打点');
return;
}
if (!formData.value.envGradeId) {
openToast('warn', '请选择风速等级');
return;
}
if (!formData.value.licenseGradeId) {
openToast('warn', '请选择执照等级');
return;
}
createAirfield(formData.value).then(() => {
openToast('success', '成功创建场地');
onBack();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
function onBack() {
Taro.navigateBack().then(() => {
resetFormData();
})
}
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
// title: '',
// bottom: '',
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
</script>
<template>
<div :class="s.root" :catch-move="true">
<div class="title">创建场地</div>
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="row">
<div class="label">场地名称</div>
<div class="ipt">
<nut-input v-model="formData.name" placeholder="请输入名称" />
</div>
</div>
<div class="row">
<div class="label">风速等级</div>
<div class="ipt">
<nut-radio-group v-model="formData.envGradeId">
<nut-radio v-for="item in envGradeOptions" :label="item.value" :key="item.value" shape="button">{{ item.text }}</nut-radio>
</nut-radio-group>
</div>
</div>
<div class="row">
<div class="label">执照等级</div>
<div class="ipt">
<nut-radio-group v-model="formData.licenseGradeId">
<nut-radio v-for="item in licenseGradesOptions" :label="item.value" :key="item.value" shape="button">{{ item.text }}</nut-radio>
</nut-radio-group>
</div>
</div>
<div class="row">
<div class="label">左侧圆心</div>
<div class="ipt">
<nut-button type="info" @click="createCircle(1)">打点</nut-button>
</div>
</div>
<div class="row">
<div class="label">右侧圆心</div>
<div class="ipt">
<nut-button type="info" @click="createCircle(2)">打点</nut-button>
</div>
</div>
<div class="submit">
<nut-button type="primary" shape="square" @click="onCreateField">创建</nut-button>
<nut-button type="info" shape="square" @click="onBack">返回</nut-button>
</div>
</div>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 11px;
width: 200px;
//min-width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 10px;
:global {
.title {
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid snow;
padding: 6px 0;
font-size: 16px;
}
.row {
margin-top: 6px;
display: flex;
align-items: center;
& + .row {
}
.label {
width: 60px;
text-align: right;
}
.ipt {
.nut-input {
padding: 0;
font-size: inherit;
background-color: transparent;
color: white;
border-bottom: 1px solid rgba(245, 245, 245, 0.44);
.input-text {
font-size: inherit;
}
}
.nut-radio-group {
display: flex;
gap: 5px;
.nut-radio {
margin: 0;
.nut-radio__button {
padding: 2px 8px;
font-size: 9px;
}
}
}
.nut-button {
padding: 2px 6px;
font-size: 11px;
max-height: 20px;
}
}
}
.submit {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
//margin-top: 40px;
display: flex;
flex-direction: column;
padding: 0 10px;
.nut-button {
padding: 2px 6px;
font-size: 14px;
max-height: 20px;
+ .nut-button {
margin-top: 10px;
}
}
}
//.bottom {
// border-top: 1px solid snow;
// display: flex;
// //ali
//
// .nut-button {
// padding: 2px 6px;
// font-size: 11px;
// max-height: 20px;
// }
//}
}
}
</style>

118
src/pages/airfieldMap/geo.js

@ -0,0 +1,118 @@
/**
* 地理空间函数库
*/
import * as math from 'mathjs';
import * as turf from '@turf/turf';
/**
* 将两个点连成一个向量并确保落在14象限
* @param point1
* @param point2
* @returns {Vector}
*/
export function pointToVector(point1, point2) {
const vec = math.subtract(point2, point1);
return vec[0] < 0 ? math.multiply(-1, vec) : vec;
}
/**
* 弧度2角度
* @param radians
* @returns {number}
*/
export function radToDeg(radians) {
return math.multiply(radians, math.divide(180, math.pi));
}
/**
* 计算二维向量与x轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithXAxis(vector) {
const [x] = vector;
const magnitude = math.norm(vector);
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = x / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算二维向量与 y 轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithYAxis(vector) {
const [, y] = vector;
const magnitude = math.norm(vector); // 计算向量的模长 ||v||
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = y / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = math.max(-1, math.min(1, cosTheta));
// 计算夹角 theta
return math.acos(safeCosTheta);
}
/**
* 计算两个二维向量之间的夹角弧度
* @param {number[]} vectorA - 第一个二维向量 [x1, y1]
* @param {number[]} vectorB - 第二个二维向量 [x2, y2]
* @returns {number} 夹角的弧度值
*/
export function angleBetweenVectors(vectorA, vectorB) {
const dotProduct = math.dot(vectorA, vectorB);
const magnitudeA = math.norm(vectorA);
const magnitudeB = math.norm(vectorB);
if (magnitudeA === 0 || magnitudeB === 0) {
throw new Error('其中一个向量是零向量,夹角未定义');
}
const cosTheta = dotProduct / (magnitudeA * magnitudeB);
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算两个坐标点的中点
* @param point1
* @param point2
* @returns {number[]}
*/
export function midPoint(point1, point2) {
const [x1, y1] = point1;
const [x2, y2] = point2;
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [+x.toFixed(8), +y.toFixed(8)];
}
/**
* 生成圆形路径
* @param center 圆心[lng, lat]
* @param radius 半径单位
* @param steps 分段数
* @returns {any}
*/
export function genCirclePath(center, radius, steps = 64) {
const geojson = turf.circle(center, radius / 1000, {
steps,
units: 'kilometers',
});
const [coords] = turf.getCoords(geojson);
return coords;
}

5
src/pages/airfieldMap/index.config.js

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '场地',
navigationStyle: 'custom',
pageOrientation: 'landscape'
})

444
src/pages/airfieldMap/index.vue

@ -0,0 +1,444 @@
<script setup>
import {onMounted, ref, onUnmounted, reactive, watch, computed} from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import LeftSide from './LeftSide.vue';
import RightSide from './RightSide.vue';
import BottomSide from './BottomSide.vue';
import { creatEightShaped } from '../flightMap/utils';
// import ResultModal from './ResultModal.vue';
import { useAirFieldsStore, useStandardStore, useSupervisionStore } from "../../stores";
import { useDroneMarker } from "./useDroneMarker";
import { storeToRefs } from 'pinia';
import { GPS2GCJ } from '../../utils/helpers';
const { markers, rotate, circles, createCircle, showEight, polygons, setCallBack, polyline, distanceText } = useDroneMarker();
const { getExamList, getEnvList } = useStandardStore()
const { wsDroneData } = useSupervisionStore();
const { position, attitude, battery, gps, tip, deviation, connectLoading, connectDrone, droneOnLine } = storeToRefs(useSupervisionStore());
const { getAirFieldsOfStudent } = useAirFieldsStore();
const { formData } = storeToRefs(useAirFieldsStore());
const { examList, envList } = storeToRefs(useStandardStore());
const envItem = computed(() => {
return envList.value.find((item) => item.id === formData.value.envGradeId) || {};
});
const standardData = computed(() => {
return examList.value.find((item) => item.name === '8字圆圈半径' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
const standardDiffData = computed(() => {
return examList.value.find((item) => item.name === '8字水平偏差阈值' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
const point3standardData = computed(() => {
return examList.value.find((item) => item.name === '点3中心筒范围-内' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
getExamList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
const { params } = Taro.useRouter();
console.log('params', params);
//
let mapContext = null;
const show = ref(true);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = false;
connect();
// init();
// showEight();
});
onUnmounted(() => {
// eventBus.off('show-teaching-track-replay')
if (ws) {
confirmClose.value = true;
ws.close();
ws = null;
}
})
//
// const polygons = ref([]);
//
// const circles = ref([]);
//
const markerPoints = ref([]);
//
const bearing = ref(0);
let ws;
const confirmClose = ref(false);
function connect() {
// if (!params?.droneSn) {
// openToast('warn', '');
// return;
// }
wsDroneData(params?.droneSn).then(wsTask => {
ws = wsTask;
wsTask.onMessage( () => {
if (!center.value.lat || !center.value.lng) {
center.value.lat = position.value.lat;
center.value.lng = position.value.lng;
}
// if (firstMarker.value && position.value?.lng) {
// initDevice({ ...position.value, ...attitude.value }, bearing.value);
// firstMarker.value = false;
// } else {
// moveDevice({ ...position.value, ...attitude.value }, bearing.value)
// }
});
wsTask.onError(() => {
// clearDevice()
// setTimeout(() => {
// sum += 1;
// connect();
// }, 3000);
ws.close()
});
wsTask.onClose(() => {
// initDevice();
if (confirmClose.value) return;
ws.close();
setTimeout(() => {
// sum += 1;
connect();
}, 3000);
})
});
}
const enableRotate = ref(false);
// function init() {
// //
// Promise.all([
// getExamList(),
// getEnvList(),
// // getAirFieldsDetail('9' || '10'),
// // getAirFieldsOfStudent(),
// ]).then(([examlist, envlist, airFieldsData]) => {
// console.log('air', airFieldsData);
// // const airfield = airFieldsData?.data || {};
// const { records = [] } = examlist || {}
// const envItem = (envlist || []).find((item) => item.id === airfield.envGradeId) || {}
// const standardData = records.find(
// (item) =>
// item.name === '8' &&
// item.licenseLevelId === airfield.licenseGradeId &&
// item.envGrade === envItem.gradeName,
// )
// const standardDiffData = records.find(
// (item) =>
// item.name === '8' &&
// item.licenseLevelId === airfield.licenseGradeId &&
// item.envGrade === envItem.gradeName,
// )
// const point3standardData = records.find(
// (item) =>
// item.name === '3-' &&
// item.licenseLevelId === airfield.licenseGradeId &&
// item.envGrade === envItem.gradeName,
// );
//
// const center1 = GPS2GCJ([airfield.circle1Lng, airfield.circle1Lat]);
// const center2 = GPS2GCJ([airfield.circle2Lng, airfield.circle2Lat]);
//
// const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped(
// {
// center: center1,
// radius: standardData.value,
// radiusDiff: standardDiffData.value,
// centerWidth: 0.1,
// },
// {
// center: center2,
// radius: standardData.value,
// radiusDiff: standardDiffData.value,
// centerWidth: 0.1,
// },
// [6, 0, 1, 3, 4, 5, 2],
// { radius: point3standardData.value },
// );
//
// // //
// // polygons.value = shapePolygons;
// // setTimeout(() => {
// // circles.value = shapeCircles;
// // markerPoints.value = shapeMarkers;
// // }, 500);
//
// //
// mapContext.includePoints({
// points: [
// { latitude: center1[1], longitude: center1[0] },
// { latitude: center2[1], longitude: center2[0] }
// ],
// // padding: [130, 50, 130, 10],
// success: (res) => {
// setTimeout(() => {
// //
// const angle = turf.bearing(center1, center2);
// // 使
// rotate.value = 90 - angle;
// bearing.value = 90 - angle;
// connect();
// }, 500);
// // console.log('', res);
// },
// fail: (err) => {
// enableRotate.value = true;
// console.error('', err);
// }
// });
// }).catch(({ msg }) => {
// if (msg) openToast('warn', msg);
// })
// }
const center = ref({
lat: 0,
lng: 0,
})
setCallBack((index) => {
openToast('success', `成功打点圆${index}`);
})
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
// title: '',
// bottom: '',
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
function onHandleCreateCircle(index) {
if (gps.value.fixType !== 'GPS_FIX_TYPE_RTK_FIXED' && gps.value.fixType !== 'GPS_FIX_TYPE_RTK_FLOAT') {
openToast('warn', '请确保无人机处于RTK模式下');
return;
}
// let next;
if (index === 2 && formData.value.circle1Lat) {
// const center1 = [position.value.lng, position.value.lat];
// const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
const center1 = turf.point([position.value?.lng, position.value?.lat]);
const center2 = turf.point(GPS2GCJ([formData.value.circle1Lng, formData.value.circle1Lat]));
const dis = turf.distance(center1, center2, { units: 'meters' }).toFixed(1);
const tmp = (standardData.value?.value || 0) * 2;
console.log('ada', dis, tmp);
if (Math.abs(tmp - dis) > 0.5) {
openToast('warn', '请确保误差处于50公分内');
return;
}
}
if (index === 1 && formData.value.circle2Lat) {
// const center1 = [position.value.lng, position.value.lat];
// const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
const center1 = turf.point([position.value?.lng, position.value?.lat]);
const center2 = turf.point(GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]));
const dis = turf.distance(center1, center2, { units: 'meters' }).toFixed(1);
const tmp = (standardData.value?.value || 0) * 2;
if (Math.abs(tmp - dis) > 0.5) {
openToast('warn', '请确保误差处于50公分内');
return;
}
}
createCircle(index);
const { circle1Lat, circle1Lng, circle2Lat, circle2Lng } = formData.value;
if (circle1Lat && circle1Lng && circle2Lng && circle2Lat) {
const center1 = GPS2GCJ([formData.value.circle1Lng, formData.value.circle1Lat]);
const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
bearing.value = 90 - turf.bearing(center1, center2);
rotate.value = bearing.value;
showEight();
}
}
watch([() => formData.value.envGradeId, () => formData.value.licenseGradeId], () => {
const { circle1Lat, circle1Lng, circle2Lat, circle2Lng } = formData.value;
if (circle1Lat && circle1Lng && circle2Lng && circle2Lat) {
const center1 = GPS2GCJ([formData.value.circle1Lng, formData.value.circle1Lat]);
const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
bearing.value = 90 - turf.bearing(center1, center2);
rotate.value = bearing.value;
showEight();
}
})
// function showEight() {
// const center1 = GPS2GCJ([formData.value.circle1Lng, formData.value.circle1Lat]);
// const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
//
// // const center1 = [-122.3895127, 37.6280898];
// // const center2 = [-122.3894255, 37.6281725];
// console.log(center1, center2);
// const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
// center: center1,
// radius: standardData.value.value,
// radiusDiff: standardDiffData.value.value,
// centerWidth: 0.1,
// }, {
// center: center2,
// radius: standardData.value.value,
// radiusDiff: standardDiffData.value.value,
// centerWidth: 0.1,
// },
// [6, 0, 1, 3, 4, 5, 2],
// { radius: point3standardData.value });
// extCircles.value = [...shapeCircles];
// polygons.value = [...shapePolygons];
// extMarker.value = [...shapeMarkers];
// // }
// }
const extCircles = ref([])
// const extCircles = ref([])
const extMarker = ref([])
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:markers="[...markerPoints, ...markers, ...extMarker, ...distanceText]"
:longitude="center.lng"
:latitude="center.lat"
:polygons="polygons"
:polyline="[...polyline]"
:scale="20"
:circles="[...circles, ...extCircles]"
:enable-rotate="enableRotate"
:rotate="bearing"
:enable-satellite="true"
:show-compass="true"
/>
</view>
<RightSide @create="onHandleCreateCircle" />
<BottomSide :info="{ ...position, ...attitude, ...gps, ...battery, connectLoading, connectDrone, droneOnLine }"/>
</view>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
</template>
<style lang="less" module="s">
page {
height: 100%;
overflow: hidden;
}
.root {
position: relative;
width: 100%;
height: 100%;
:global {
.mapBox {
width: 100%;
height: 100%;
position: relative;
#map {
width: 100%;
height: 100%;
transform: scale(2);
}
.exam-result-modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.modal-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 12px;
min-width: 280px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
color: #666;
}
}
.result-status {
font-size: 20px;
font-weight: bold;
text-align: center;
margin-bottom: 16px;
color: #ff4d4f;
&.pass {
color: #52c41a;
}
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
.label {
color: #838383;
margin-right: 8px;
font-size: 14px;
}
.value {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
.real-time-data {
z-index: 1;
}
}
}
</style>

224
src/pages/airfieldMap/useConnector.js

@ -0,0 +1,224 @@
import { createGlobalState, useWebSocket } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import * as turf from '@turf/turf';
import gcoord from 'gcoord';
import * as urls from '../config/urls';
import * as geo from '../utils/geo';
import { GPS_FIX_TYPE, GPS_FIX_TYPE2 } from '../config/gpsFixTypeMap';
import { FLY_MODE } from '../config/flyModeMap';
import { FC_SYSTEM_STATUS } from '../config/fcSystemStatus';
import { ERRORS_COUNT_1, ERRORS_COUNT_2, ERRORS_COUNT_3 } from '../config/errorMap';
import { useAnnouncer } from './useAnnouncer';
const announcer = useAnnouncer();
export const useConnector = createGlobalState(() => {
const { ws, status, data, send, open, close } = useWebSocket(urls.WS_URL, {
immediate: false,
heartbeat: {
interval: 3000,
},
});
watch(data, (val) => {
if (val === 'ping') {
send('pong');
}
});
const isConnecting = computed(() => status.value === 'CONNECTING');
const isConnected = computed(() => status.value === 'OPEN');
const time = ref({});
const battery = ref({});
const gps = ref({});
const position = ref({});
const attitude = ref({});
const home = ref({});
const homeAngle = ref(0);
const modeName = ref('N/A');
const sysStatus = ref('N/A');
const extra = ref({});
const info = computed(() => {
let result = data.value || '';
if (!`${data.value}`.startsWith('{')) {
return {};
}
try {
result = JSON.parse(result);
} catch (e) {
return {};
}
return result;
});
// 闲置计时器(闲置时做些动作)
let idleTimer = null;
watch(info, (val) => {
// info有变化时,清掉上一个计时器
if (idleTimer) clearTimeout(idleTimer);
// 电池信息、故障信息
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#22-%E7%B3%BB%E7%BB%9F%E7%8A%B6%E6%80%81-sys_status
if (val?.msgId === 1) {
const { VoltageBattery, CurrentBattery, BatteryRemaining, ErrorsCount1, ErrorsCount2, ErrorsCount3 } = val.data || {};
const voltage = +(VoltageBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cV即厘伏(mavlink文档中写的是mV即毫伏)
const current = +(CurrentBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cA即厘安
const remaining = BatteryRemaining >= 0 ? BatteryRemaining : null; // 单位:%
battery.value = { voltage, current, remaining };
// todo 临时
if (ErrorsCount1) {
const { [ErrorsCount1]: errorContent } = ERRORS_COUNT_1;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount2) {
const { [ErrorsCount2]: errorContent } = ERRORS_COUNT_2;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount3) {
const { [ErrorsCount3]: errorContent } = ERRORS_COUNT_3;
announcer.alarmThrottle(errorContent);
}
}
// 系统时间
// https://mavlink.io/en/messages/common.html#SYSTEM_TIME
if (val?.msgId === 2) {
const { TimeUnixUsec, TimeBootMs } = val.data || {};
const timestamp = parseInt(TimeUnixUsec / 1e3, 10); // to毫秒
const boot = TimeBootMs; // 毫秒
time.value = { timestamp, boot };
}
// GPS(with RTK)
// https://mavlink.io/en/messages/common.html#GPS2_RAW
if (val?.msgId === 124) {
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const fixTypeLabel = GPS_FIX_TYPE2.get(fixType);
gps.value = { fixType, fixTypeLabel, satellite };
}
// GPS(普通GPS)
// https://mavlink.io/en/messages/common.html#GPS_INPUT
if (val?.msgId === 232) {
if ('fixType' in gps.value) return;
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const { [fixType]: fixTypeLabel } = GPS_FIX_TYPE;
gps.value = { fixType, fixTypeLabel, satellite };
}
// 飞机位置
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#25-%E8%9E%8D%E5%90%88%E7%BB%8F%E7%BA%AC%E5%BA%A6%E9%80%9F%E5%BA%A6-global_position_int
if (val?.msgId === 33) {
const { Lon, Lat, Alt, RelativeAlt, Vx, Vy, Vz } = val.data || {};
const [lng, lat] = gcoord.transform([Lon / 1e7, Lat / 1e7], gcoord.WGS84, gcoord.GCJ02);
const alt = Alt / 1e3; // 源值毫米
const height = RelativeAlt / 1e3; // 源值毫米
const sx = (+Vx || 0) / 1e2; // 向北速度分量(源值厘米)
const sy = (+Vy || 0) / 1e2; // 向东速度分量(源值厘米)
const sz = (+Vz || 0) / 1e2; // 向下速度分量(源值厘米)
const hSpeed = Math.hypot(sx, sy);
const vSpeed = Math.abs(sz);
const { lng: hLng, lat: hLat } = home.value || {};
const homeDist = hLng !== undefined ? turf.distance([lng, lat], [hLng, hLat]) * 1e3 : null; // distance单位是km
position.value = { lng, lat, alt, height, hSpeed, vSpeed, sx, sy, sz, homeDist };
}
// 飞机姿态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#26-%E9%A3%9E%E6%9C%BA%E5%A7%BF%E6%80%81%E8%A7%92%E9%80%9F%E5%BA%A6-attitude
if (val?.msgId === 30) {
const { Yaw, Pitch, Roll } = val.data || {};
const yaw = +geo.radToDeg(Yaw || 0).toFixed(1);
const pitch = +geo.radToDeg(Pitch || 0).toFixed(1);
const roll = +geo.radToDeg(Roll || 0).toFixed(1);
attitude.value = { yaw, pitch, roll };
}
// 油门
// https://mavlink.io/en/messages/common.html#VFR_HUD
if (val?.msgId === 74) {
const { Throttle } = val.data || {};
extra.value = { throttle: Throttle };
}
// home点
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#23-home%E7%82%B9-home_position
if (val?.msgId === 242) {
const { Longitude, Latitude } = val.data || {};
let lng = +(Longitude / 1e7).toFixed(7);
let lat = +(Latitude / 1e7).toFixed(7);
[lng, lat] = [lat, lng]; // todo 临时颠倒一下
home.value = { lng, lat };
}
// 飞行模式、解锁状态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#110-%E8%87%AA%E5%AE%9A%E4%B9%89%E9%A3%9E%E8%A1%8C%E6%A8%A1%E5%BC%8F-vkfly_custom_mode
// https://mavlink.io/en/messages/common.html#HEARTBEAT
if (val?.msgId === 0) {
const { CustomMode, SystemStatus } = val.data || {};
const { [CustomMode]: label } = FLY_MODE;
modeName.value = label || 'N/A';
sysStatus.value = FC_SYSTEM_STATUS.get(SystemStatus) || 'N/A';
}
// 1秒后,若info没有新的变化,则清空这些数据
idleTimer = setTimeout(() => {
time.value = {};
battery.value = {};
gps.value = {};
position.value = {};
attitude.value = {};
home.value = {};
modeName.value = 'N/A';
sysStatus.value = 'N/A';
}, 1000);
});
// 动态计算机头与home点的夹角
watch([position, attitude], () => {
const { lng: hLng, lat: hLat } = home.value || {};
if (hLng === undefined) {
homeAngle.value = 0;
return;
}
const { lng: dLng, lat: dLat } = position.value || {};
const { yaw } = attitude.value || {};
if (dLng === undefined || yaw === undefined) return;
const [lng, lat] = [hLng - dLng, hLat - dLat]; // 以飞机为起点,home为终点的向量
// 不能是0向量(飞机与home点完全重合时,则令机头始终指向home)
if (!lng && !lat) {
homeAngle.value = 0;
return;
}
const rad = geo.angleWithYAxis([lng, lat]); // 与(0,1)这个单位向量的夹角
const deg = geo.radToDeg(rad) * (lng >= 0 ? 1 : -1); // 通过x轴正负来决定角度正负
homeAngle.value = +(deg - yaw).toFixed(2) || 0;
});
return {
ws,
isConnecting,
isConnected,
time,
battery,
gps,
position,
attitude,
home,
homeAngle,
modeName,
sysStatus,
extra,
send,
connect: open,
reconnect: open,
close,
};
});
export default null;

320
src/pages/airfieldMap/useDroneMarker.js

@ -0,0 +1,320 @@
import {computed, onMounted, ref} from 'vue';
import deviceIcon from "../../assets/droneImg.png";
import droneDisImg from '../../assets/droneDisImg.png';
import Taro from "@tarojs/taro";
import {storeToRefs} from "pinia";
import {useAirFieldsStore, useStandardStore, useSupervisionStore} from "../../stores";
import {GCJ2GPS, GPS2GCJ} from "../../utils/helpers";
import {creatEightShaped} from "../flightMap/utils";
import * as turf from "@turf/turf";
import transparentImg from "../../assets/transparent-marker.png";
const { formData } = storeToRefs(useAirFieldsStore());
const { examList, envList } = storeToRefs(useStandardStore());
const envItem = computed(() => {
return envList.value.find((item) => item.id === formData.value.envGradeId) || {};
});
const standardData = computed(() => {
return examList.value.find((item) => item.name === '8字圆圈半径' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
const standardDiffData = computed(() => {
return examList.value.find((item) => item.name === '8字水平偏差阈值' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
const point3standardData = computed(() => {
return examList.value.find((item) => item.name === '点3中心筒范围-内' && item.licenseLevelId === formData.value.licenseGradeId && item.envGrade === envItem.value.gradeName) || {};
});
export function useDroneMarker() {
let mapContext;
const { position, attitude, droneOnLine } = storeToRefs(useSupervisionStore());
const rotate = ref(0);
const extMarker = ref([]);
const markers = computed(() => {
if (!Object.keys(position.value).length || !mapContext) return [...extMarker.value];
return [{
id: 1e7,
iconPath: droneOnLine.value ? deviceIcon : droneDisImg,
width: 18,
height: 18,
anchor: { x: 0.5, y: 0.5 },
latitude: position.value?.lat,
longitude: position.value?.lng,
rotate: attitude.value?.yaw + rotate.value,
}, ...extMarker.value]
})
const circle1 = ref([]);
const circle2 = ref([]);
const currentCircle = computed(() => {
if (!Object.keys(position.value).length || !mapContext) return [];
return [{
latitude: position.value?.lat,
longitude: position.value?.lng,
color: '#00d9ff',
radius: standardData.value.value || 6,
// fillColor: '#00000000',
strokeWidth: 0.8
}]
});
const extCircles = ref([]);
const circles = computed(() => [...circle1.value, ...circle2.value, ...currentCircle.value, ...extCircles.value]);
const polygons = ref([]);
const distanceText = computed(() => {
if (!droneOnLine.value) {
return []
}
if (circle1.value.length) {
const center1 = turf.point([position.value?.lng, position.value?.lat]);
const center2 = turf.point([circle1.value[0]?.longitude, circle1.value[0]?.latitude]);
const text = turf.distance(center1, center2, { units: 'meters' }).toFixed(1);
const [lng, lat] = turf.midpoint(center1, center2).geometry.coordinates
return [
{
id: 1e7 + 9,
latitude: lat,
longitude: lng,
iconPath: transparentImg,
width: 1,
height: 1,
label: {
content: `${text || 0} m`,
color: '#000000',
fontSize: 8,
// textStrokeWidth: 2,
// textStrokeColor: '#007fcf',
anchorX: -3,
anchorY: 0,
bgColor: '#00000000'
}
}
]
}
if (circle2.value.length) {
const center1 = turf.point([position.value?.lng, position.value?.lat]);
const center2 = turf.point([circle2.value[0]?.longitude, circle2.value[0]?.latitude]);
const text = turf.distance(center1, center2, { units: 'meters' }).toFixed(1);
const [lng, lat] = turf.midpoint(center1, center2).geometry.coordinates;
return [
{
id: 1e7 + 9,
latitude: lat,
longitude: lng,
iconPath: transparentImg,
width: 1,
height: 1,
label: {
content: `${text || 0} m`,
color: '#000000',
fontSize: 8,
// textStrokeWidth: 2,
// textStrokeColor: '#007fcf',
anchorX: -3,
anchorY: 0,
bgColor: '#00000000'
}
}
]
}
return [];
})
const polyline = computed(() => {
if (!droneOnLine.value) {
return []
}
if (circle1.value.length) {
return [
{
points: [
{
latitude: position.value?.lat,
longitude: position.value?.lng,
},
{
latitude: circle1.value[0]?.latitude,
longitude: circle1.value[0]?.longitude,
},
],
color: '#FF0000',
width: 0.8,
dottedLine: true,
level:'abovebuildings',
}
]
}
if (circle2.value.length) {
return [
{
points: [
{
latitude: position.value?.lat,
longitude: position.value?.lng,
},
{
latitude: circle2.value[0]?.latitude,
longitude: circle2.value[0]?.longitude,
},
],
color: '#FF0000',
width: 0.8,
dottedLine: true,
level:'abovebuildings',
// segmentTexts: [{
// name: 'ccccc',
// startIndex: 0,
// endIndex: 1,
// }],
// textStyle: {
// fontSize: 20,
// textColor: '#000000'
// }
}
]
}
return [];
})
onMounted(() => {
mapContext = Taro.createMapContext('map');
})
let callBack = () => {};
function createCircle(index = 1) {
const { lat, lng } = position.value || {};
if (index === 1) {
circle1.value = [
{
latitude: lat,
longitude: lng,
color: '#FF0000',
radius: standardData.value.value || 6,
// fillColor: '#00000000',
strokeWidth: 0.8
},
{
latitude: lat,
longitude: lng,
color: '#FF0000',
radius: 0.1,
fillColor: '#FF0000',
strokeWidth: 0
},
];
const [Lng, Lat] = GCJ2GPS([lng, lat]);
formData.value.circle1Lat = Lat;
formData.value.circle1Lng = Lng;
callBack(index);
}
if (index === 2) {
circle2.value = [
{
latitude: lat,
longitude: lng,
color: '#FF0000',
radius: standardData.value.value || 6,
// fillColor: '#00000000',
strokeWidth: 0.8
},
{
latitude: lat,
longitude: lng,
color: '#FF0000',
radius: 0.1,
fillColor: '#FF0000',
strokeWidth: 0
},
]
const [Lng, Lat] = GCJ2GPS([lng, lat]);
formData.value.circle2Lat = Lat;
formData.value.circle2Lng = Lng;
callBack(index);
}
}
function setCallBack(fn = () => {}) {
callBack = fn;
}
// function moveDevice(point, mapRotate = 0) {
// const { lng, lat, yaw } = point;
//
// map.translateMarker({
// markerId,
// destination: {
// longitude: lng,
// latitude: lat,
// },
// autoRotate: false,
// duration: 1,
// rotate: yaw + mapRotate,
// moveWithRotate: true,
// animationEnd: () => {
// // this._that.timelyData = { ...nextData };
// // this._that.currentIndex += 1;
// // if (this._that.isPlaying) {
// // this._that.elapsedMs += (duration * this._that.speedRate);
// // if (this._that.elapsedMs >= this._that.totalTime) {
// // this._that.handleStop();
// // return;
// // }
// // this._that._renderDevice();
// // }
// }
// });
// }
// const polyine = ref();
// function renderTrack(points = []) {
//
// }
function showEight() {
const center1 = GPS2GCJ([formData.value.circle1Lng, formData.value.circle1Lat]);
const center2 = GPS2GCJ([formData.value.circle2Lng, formData.value.circle2Lat]);
// const center1 = [-122.3895127, 37.6280898];
// const center2 = [-122.3894255, 37.6281725];
// console.log(center1, center2);
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
center: center1,
radius: standardData.value.value,
radiusDiff: standardDiffData.value.value,
centerWidth: 0.1,
}, {
center: center2,
radius: standardData.value.value,
radiusDiff: standardDiffData.value.value,
centerWidth: 0.1,
},
[6, 0, 1, 3, 4, 5, 2],
{ radius: point3standardData.value.value });
extCircles.value = [...shapeCircles];
polygons.value = [...shapePolygons];
extMarker.value = [...shapeMarkers];
// }
}
return {
// initDevice,
// moveDevice,
markers,
rotate,
circles,
polygons,
createCircle,
showEight,
setCallBack,
polyline,
distanceText,
}
}

40
src/pages/flight/index.vue

@ -7,6 +7,7 @@
const { getFlightList } = useFlightStore();
const { flightList, flightExtra, flightQueries } = storeToRefs(useFlightStore());
const list = ref([])
const state = reactive({
msg: '错误提示',
type: 'warn',
@ -28,6 +29,7 @@
isRefreshing.value = true;
flightQueries.value.pageNum = 1;
getFlightList().then(() => {
list.value = [...flightList.value];
Taro.stopPullDownRefresh();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
@ -36,21 +38,25 @@
});
}
onRefresh();
function onLoadMore() {
if (loading.value) return;
if (flightList.value.length >= flightExtra.value.total) return;
if (list.value.length >= flightExtra.value.total) return;
loading.value = true;
flightQueries.value.pageNum += 1;
getFlightList().catch(({ msg }) => {
getFlightList().then(() => {
list.value = [...list.value, ...flightList.value];
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
}
function onNavTo(id) {
function onNavTo(recordId) {
Taro.navigateTo({
url: `/pages/flightMap/index?id=${id}`,
url: `/pages/flightMap/index?recordId=${recordId}`,
});
}
@ -62,20 +68,20 @@
onLoadMore();
});
Taro.useDidShow(() => {
getFlightList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
});
// Taro.useDidShow(() => {
// getFlightList().catch(({ msg }) => {
// if (msg) openToast('warn', msg);
// });
// });
</script>
<template>
<div :class="s.root">
<div class="list">
<div class="item" v-for="item in flightList" :key="item.id" :class="{ 'is-pass': item.isPass }">
<div class="item" v-for="item in list" :key="item.recordId" :class="{ 'is-pass': item.isPass }">
<div class="title">
<span>{{ item.studentName || '-' }}</span>
<nut-button size="mini" type="info" @click="onNavTo(item.id)">回放</nut-button>
<nut-button size="mini" type="info" @click="onNavTo(item.recordId)">回放</nut-button>
</div>
<div class="info">
<div class="info-row">
@ -97,12 +103,12 @@
</div>
</div>
</div>
<nut-empty v-if="!loading && !flightList.length" description="暂无数据" />
<nut-empty v-if="!loading && !list.length" description="暂无数据" />
<nut-infiniteloading
v-if="flightList.length"
v-if="list.length"
load-txt="加载中..."
load-more-txt="没有更多了"
:has-more="flightList.length < flightExtra.total"
:has-more="list.length < flightExtra.total"
@load-more="onLoadMore"
/>
</div>
@ -117,7 +123,7 @@
padding: 20px 0;
box-sizing: border-box;
background-color: #eaeaea;
overflow: hidden;
overflow: auto;
}
.root {
@ -127,8 +133,8 @@
:global {
.list {
flex: 1;
overflow: auto;
//flex: 1;
//overflow: auto;
padding: 0 20px;
.item {

113
src/pages/flightMap/BottomSide.vue

@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
import {useFlightStore} from "../../stores";
import {storeToRefs} from "pinia";
import dayjs from "dayjs";
import { popularTime } from '../../utils/helpers';
// import Taro from "@tarojs/taro";
const { flightDetail } = storeToRefs(useFlightStore());
// const isVisible = ref(true);
// const info = computed(() => ({}));
const sum = computed(() => {
const { startTime, endTime } = flightDetail.value || {};
if (startTime && endTime) {
//
const sTime = dayjs(startTime); //
const eTime = dayjs(endTime);
//
const diffSeconds = eTime.diff(sTime, 'second');
return popularTime(diffSeconds, 0, true);
}
return '-';
})
function onBack() {
console.log('aaa');
}
</script>
<template>
<div :class="s.root">
<canvas class="canvas-bg" type="2d" />
<div class="item item1">
<div class="key">开始时间</div>
<div class="time">{{ flightDetail?.startTime }}</div>
</div>
<div class="item item2">
<div class="key">结束时间</div>
<div class="time">{{ flightDetail?.endTime }}</div>
</div>
<div class="item item3">
<div class="key">考试耗时</div>
<div class="time">{{ sum }}</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0;
bottom: 0;
right: 0;
//bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
// padding: 8px;
color: white;
font-size: 11px;
//width: 100px;
height: 30px;
overflow: hidden;
box-sizing: content-box;
z-index: 2;
display: flex;
:global {
.canvas-bg {
width: 80%;
height: 50px;
position: absolute;
//top: 0;
right: 0;
//left: 0;
//bottom: 20px;
}
.item {
display: flex;
align-items: center;
justify-content: center;
+ .item {
border-left: 2px solid white;
}
.time {
margin-left: 25px;
}
}
.item1 {
flex: 1;
}
.item2 {
flex: 1;
}
.item3 {
width: 150px;
}
}
}
</style>

216
src/pages/flightMap/LeftSide.vue

@ -0,0 +1,216 @@
<script setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import successImg from '../../assets/success.png';
import failImg from '../../assets/fail.png';
// import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
import * as Taro from "@tarojs/taro";
// const isVisible = ref(true);
// const info = computed(() => ({}));
defineProps({
info: {
type: Object,
default: () => ({}),
}
});
// const url = ref();
// watch(() => props.info, (nv) => {
// const { stageInfo: { fileUrl } } = nv;
// if (audioManager && (fileUrl !== url.value)) {
// console.log(nv)
// url.value = nv;
// audioManager.src = nv;
// audioManager.play();
// }
// }, {
// deep: true,
// })
// onMounted(() => {
// initAudio();
// })
//
// onUnmounted(() => {
// if (audioManager) {
// audioManager.destroy();
// audioManager = null;
// }
// })
//
// let audioManager;
// function initAudio() {
// audioManager = Taro.createInnerAudioContext();
// // audioManager.onPlay(() => {
// // console.log('')
// // openToast();
// // })
//
// audioManager.onError((res) => {
// console.error(':', res.errMsg, res.errCode);
// });
// // audioManager.onEnded(() => {
// // temTip.value.splice(0, 1);
// // if (temTip.value.length === 0) return;
// // tmp.value = temTip.value[0];
// // audioManager.src = tmp.value.fileUrl;
// // audioManager.play();
// // });
//
// // audioManager.value.title = '';
// // console.log('tt', tt);
// // audioManager.value.src = 'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745814185096733360.mp3';
//
// // audioManager.value.play();
// // setTimeout(() => {
// // audioManager.value.src = tt2;
// // }, 5000)
//
// // audioManager.value.onPlay(() => {
// // console.log('');
// // });
// //
// // audioManager.value.onError((res) => {
// // console.log('', res);
// // //
// // });
// //
// // audioManager.value.onEnded(() => {
// // console.log('');
// // });
// }
</script>
<template>
<div :class="s.root" :catch-move="true">
<div class="box">
<div class="title">错误次数 {{ (info?.errMarker?.err || []).length }}</div>
<div class="content" v-for="(item, index) in (info?.errMarker?.err || [])" :key="index">
{{ index + 1 }}.{{ item?.text }}
</div>
</div>
<div class="box box2">
<div class="title">考试阶段</div>
<div class="content">{{ info?.stageInfo?.text }}</div>
<!-- <div class="error">{{ 操作失败 - 航向偏差过大3:41考试未通过 }}</div>-->
</div>
<div class="box3">
<image class="img" :src="info?.flightDetail?.isPass ? successImg : failImg" />
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 2px;
top: 36px;
bottom: 40px;
// padding: 8px;
//color: white;
//font-size: 11px;
//width: 100px;
z-index: 2;
//transition: transform 0.3s ease;
display: flex;
flex-direction: column;
//&.hidden {
// transform: translateX(calc(-100% - 5px));
//}
:global {
.canvas-bg {
width: 100%;
height: 80%;
position: absolute;
//top: 0;
//right: 0;
//left: 0;
//bottom: 20px;
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
width: 150px;
color: white;
background-color: rgba(0, 0, 0, 0.8);
max-height: 250px;
overflow: auto;
//color: #7FFF00;
//border-bottom: 1px solid rgb(255, 255, 255);
padding: 8px;
.title {
color: cornflowerblue;
}
//.countdown {
// font-size: 24px;
// font-weight: bold;
// text-align: center;
// padding: 5px;
//}
//.data-item {
// display: flex;
// align-items: center;
// justify-content: center;
// // gap: 12px;
// // padding: 5px;
//
// .icon-wrapper {
// width: 30px;
// height: 30px;
// display: flex;
// align-items: center;
// justify-content: center;
// font-size: 20px;
// background: rgba(127, 255, 0, 0.1);
// border-radius: 4px;
// }
//
// .value-wrapper {
// flex: 1;
// font-size: 18px;
// font-weight: 500;
// text-align: left;
// padding-left: 8px;
// }
//}
}
.box2 {
//padding: 4px 8px;
margin-top: 2px;
}
.box3 {
//flex: 1;
display: flex;
align-items: center;
justify-content: center;
//position: relative;
.img {
position: fixed;
bottom: 30px;
width: 120px;
height: 120px;
}
}
}
}
</style>

135
src/pages/flightMap/RightSide.vue

@ -0,0 +1,135 @@
<script setup>
// import {computed, onUnmounted, ref, watch} from 'vue';
// import deviceCruise from '../../core/useDeviceCruise';
import { IconFont } from '@nutui/icons-vue-taro';
import {falsyTo} from "../../utils/helpers";
import * as Taro from "@tarojs/taro";
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<div class="box">
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="compass" v-if="false" />
<div class="row">
<div class="label">航向角</div>
<div class="value">{{ falsyTo(info?.yaw) }}°</div>
</div>
</div>
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" v-if="false" />
<div class="row">
<div class="label">俯仰角</div>
<div class="value">{{ info?.pitch }}°</div>
</div>
</div>
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" v-if="false" />
<div class="row">
<div class="label">横滚角</div>
<div class="value">{{ info?.roll }}°</div>
</div>
</div>
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" v-if="false" />
<div class="row">
<div class="label">海拔</div>
<div class="value">{{ falsyTo(info?.alt) }} m</div>
</div>
</div>
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="icon-measure" v-if="false" />
<div class="row">
<div class="label">高度</div>
<div class="value">{{ falsyTo(info?.height) }} m</div>
</div>
</div>
<div class="data-item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" v-if="false" />
<div class="row">
<div class="label">GPS</div>
<div class="value">{{ falsyTo(info?.satellite) }}</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 2px;
top: 36px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 10px;
width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
padding: 8px;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.box {
display: flex;
flex-direction: column;
gap: 4px;
font-family: monospace;
color: #7FFF00;
.data-item {
display: flex;
align-items: center;
justify-content: space-between;
// flex-wrap: wrap;
// font-size: 12px;
.iconfont {
width: 10px;
height: 10px;
// line-height: 16px;
// text-align: center
font-size: 10px;
margin-right: 4px;
}
.row {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.label {
// display: flex;
// align-items: center;
// justify-content: center;
color: #ffffff;
}
.value {
text-align: right;
flex: 1;
color: #7FFF00;
font-weight: 500;
}
}
}
}
}
</style>

142
src/pages/flightMap/TopSide.vue

@ -0,0 +1,142 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
import {useFlightStore} from "../../stores";
import {storeToRefs} from "pinia";
import Taro from "@tarojs/taro";
const { flightDetail } = storeToRefs(useFlightStore());
// const { } =
const tmp = {
1: '视距内驾驶员',
2: '超视距机长',
3: '教员',
}
const weightClasses = {
small: "小型",
medium: "中型",
large: "大型",
};
function onBack() {
Taro.navigateBack();
}
</script>
<template>
<div :class="s.root">
<canvas class="canvas-bg" type="2d" />
<div class="box box1" @click="onBack">
<div class="back">返回</div>
</div>
<div class="box box2">
<div class="data-item">
<div class="value">{{ flightDetail?.studentName }}</div>
<div class="key">姓名</div>
</div>
<div class="data-item">
<div class="value">多旋翼</div>
<div class="key">机型</div>
</div>
<div class="data-item">
<div class="value">{{ weightClasses[flightDetail?.weightClass] || '?' }}无人机</div>
<div class="key">等级</div>
</div>
<div class="data-item">
<div class="value">{{ tmp[flightDetail?.licenseGrade] }}</div>
<div class="key">考试标准</div>
</div>
<div class="data-item">
<div class="value">{{ flightDetail?.airfield?.name }}</div>
<div class="key">场地</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0;
top: 0;
right: 0;
//bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
// padding: 8px;
color: white;
font-size: 11px;
//width: 100px;
//height: 40px;
overflow: hidden;
box-sizing: content-box;
z-index: 2;
display: flex;
:global {
.canvas-bg {
width: 80%;
height: 50px;
position: absolute;
//top: 0;
right: 0;
//left: 0;
//bottom: 20px;
}
.box {
padding: 4px 8px;
}
.box1 {
width: 50px;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid white;
//flex-direction: column;
//gap: 2px;
//font-family: monospace;
//color: #7FFF00;
//border-bottom: 1px solid rgb(255, 255, 255);
// padding: 4px 8px;
//.countdown {
// font-size: 24px;
// font-weight: bold;
// text-align: center;
// padding: 5px;
//}
}
.box2 {
flex: 1;
//padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-around;
.data-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
// gap: 12px;
// padding: 5px;
.key {
//font-size: 14px;
font-size: 10px;
}
}
}
}
}
</style>

2
src/pages/flightMap/index.config.js

@ -1,5 +1,5 @@
export default {
navigationBarTitleText: '实践飞行回放',
navigationStyle: 'default',
navigationStyle: 'custom',
pageOrientation: 'landscape'
};

317
src/pages/flightMap/index.vue

@ -2,150 +2,263 @@
import { onMounted, ref, watch, onUnmounted, computed } from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import { creatEightShaped, convertToTrajectory } from './utils';
import trackData from './track.json';
import deviceCruise from '../../core/deviceCruise';
import { creatEightShaped } from './utils';
// import trackData from './track.json';
import { useDeviceCruise } from '../../core/useDeviceCruise';
import TrackPlayback from '../../components/TrackPlayback.vue';
import RealTimeData from '../../components/RealTimeData.vue';
// import RealTimeData from '../../components/RealTimeData.vue';
import TopSide from './TopSide.vue';
import LeftSide from './LeftSide.vue';
import BottomSide from './BottomSide.vue';
import RightSide from "./RightSide.vue";
import {storeToRefs} from "pinia";
import {useFlightStore, useStandardStore} from "../../stores";
import {GPS2GCJ} from "../../utils/helpers";
import * as geo from "../../utils/geo";
import dayjs from "dayjs";
import transparentImg from "../../assets/transparent-marker.png";
import {TIP_TEXT} from "../../config/tipTextMap";
const { getEnvList, getExamList } = useStandardStore();
const { examList, envList } = storeToRefs(useStandardStore());
const { getFlightDetail, getFlightTracks } = useFlightStore();
const { flightDetail } = storeToRefs(useFlightStore());
const { params } = Taro.useRouter();
// console.log('params', params);
// Taro.hideHomeButton();
//
const centerPoint = ref({
lat: 39.908692,
lng: 116.397477
});
// //
// const centerPoint = ref({
// lat: 39.908692,
// lng: 116.397477
// });
const { markers, points, isPaused, isPlaying, rotate, timelyData, elapsedMs, totalTime, setCurrentTime, initRenderDevice, play, pause } = useDeviceCruise()
//
let mapContext = null;
const trajectory = convertToTrajectory(trackData, {
startTime: "2025-03-14T08:00:00Z",
interval: 1000 // 1
});
const show = ref(false);
let audioManager;
// audioManager = Taro.createInnerAudioContext();
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = true;
console.log(mapContext);
initEightShaped();
initDeviceCruise();
init();
// initEightShaped();
// initDeviceCruise();
});
//
let eightShaped = null;
//
const polygons = ref([]);
//
const circles = ref([]);
//
const markerPoints = ref([]);
//
const polyline = ref([{
points: trackData.map(coord => ({
latitude: coord[1],
longitude: coord[0]
})),
color: '#008000',
width: 4,
arrowLine: true
}]);
//
const bearing = ref(0);
//
function initEightShaped() {
if (!mapContext) return;
const center1 = [106.630154, 26.647661];
const center2 = [106.632154, 26.649661];
//
let angle = turf.bearing(center1, center2);
// 90使
// 0-360
angle = ((angle % 360) + 360) % 360;
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
function init() {
//
Promise.all([
getExamList(),
getEnvList(),
getFlightDetail(params?.recordId),
// getAirFieldsOfStudent(),
]).then(([examlist, envlist, recordInfo]) => {
const airfield = recordInfo?.data?.airfield || {};
const { records = [] } = examlist || {}
const envItem = (envlist || []).find((item) => item.id === airfield.envGradeId) || {}
const standardData = records.find(
(item) =>
item.name === '8字圆圈半径' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const standardDiffData = records.find(
(item) =>
item.name === '8字水平偏差阈值' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const point3standardData = records.find(
(item) =>
item.name === '点3中心筒范围-内' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
);
const center1 = GPS2GCJ([airfield.circle1Lng, airfield.circle1Lat]);
const center2 = GPS2GCJ([airfield.circle2Lng, airfield.circle2Lat]);
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped(
{
center: center1,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, {
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
{
center: center2,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, [6, 0, 1, 3, 4, 5, 2], { radius: 50 });
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
[6, 0, 1, 3, 4, 5, 2],
{ radius: point3standardData.value },
);
//
polygons.value = shapePolygons;
setTimeout(() => {
circles.value = shapeCircles;
markerPoints.value = shapeMarkers;
circles.value = [
...shapeCircles,
{
latitude: center1[1],
longitude: center1[0],
color: '#FF0000',
fillColor: '#FF0000',
radius: 5,
strokeWidth: 2
},
{
latitude: center2[1],
longitude: center2[0],
color: '#FF0000',
fillColor: '#FF0000',
radius: 5,
strokeWidth: 2
}
];
}, 500);
//
// 使
mapContext.includePoints({
points: [
{ latitude: center1[1], longitude: center1[0] },
{ latitude: center2[1], longitude: center2[0] }
],
padding: [50, 50, 110, 50],
// padding: [130, 50, 130, 10],
success: (res) => {
setTimeout(() => {
//
const angle = turf.bearing(center1, center2);
// 使
bearing.value = ((angle + 5) % 360 + 360) % 360;
bearing.value = 90 - angle;
initDeviceCruise();
}, 500);
// bearing.value = angle.toFixed(2) - 85;
console.log('地图视野设置成功', res);
// console.log('', res);
},
fail: (err) => {
console.error('地图视野设置失败', err);
}
});
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
//
// watch(() => centerPoint.value, () => {
// if (!mapContext || !eightShaped) return;
// //
// initEightShaped();
// }, { deep: true });
const polyline = ref([]);
const trackList = ref([]);
const errMarker = computed(() => {
const tagInfo = flightDetail.value?.details || [];
const err = [];
if (tagInfo.length) {
tagInfo.forEach((item) => {
if (item.eventType === 1) {
const tp = dayjs(item?.happenTime).valueOf();
console.log('tp', tp);
const errPoint = trackList.value.find(point => point.timestamp >= tp);
if (errPoint) {
err.push({ ...item, ...errPoint, ...(TIP_TEXT[item?.audioCode] || {}) });
}
}
})
}
const marker = err.map(((err, index) => {
return {
id: 1e5 + index,
latitude: err.lat,
longitude: err.lng,
iconPath: transparentImg,
width: 1,
height: 1,
label: {
content: String(index + 1),
color: '#ff0000',
fontSize: 8,
// textStrokeWidth: 2,
// textStrokeColor: '#007fcf',
anchorX: 3,
anchorY: -6,
bgColor: '#00000000'
}
}
}));
//
function initDeviceCruise() {
console.log(mapContext);
const circle = err.map((err) => {
return {
latitude: err.lat,
longitude: err.lng,
color: '#FF0000',
radius: 0.1,
fillColor: '#FF0000',
strokeWidth: 0
}
})
deviceCruise.setMap(mapContext);
deviceCruise.loadTrack(trajectory);
// console.log(err);
return {
marker,
circle,
err,
}
})
//
function initDeviceCruise(mapRotate = 0) {
// deviceCruise.setMap(mapContext);
getFlightTracks(params?.recordId).then((data) => {
// console.log(data);
trackList.value = (data?.data || []).map(item => {
const [lng, lat] = GPS2GCJ([item.lng, item.lat]);
const yaw = +geo.radToDeg((item.yaw || 0) / 100).toFixed(1);
const pitch = +geo.radToDeg((item.pitch || 0) / 100).toFixed(1);
const roll = +geo.radToDeg((item.roll || 0) / 100).toFixed(1);
const alt = (item.alt || 0) / 1e2; //
const height = (item.height || 0) / 1e2; //
return {
...item,
lat,
lng,
yaw,
pitch,
roll,
alt,
height,
}
});
polyline.value = [{
points: trackList.value.map(item => ({
latitude: item.lat,
longitude: item.lng
})),
color: '#008000',
width: 0.8,
}];
points.value = [...trackList.value];
rotate.value = bearing.value;
initRenderDevice();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
// markerPoints.value.push(deviceCruise._marker.value);
// console.log(markerPoints.value);
}
const stageInfo = computed(() => {
const info = (flightDetail.value?.details || []).find(item => timelyData.value?.timestamp <= dayjs(item?.happenTime).valueOf() && item?.eventType !== 2);
let stage= {};
if (info) {
stage = {
...info,
...(TIP_TEXT[info?.audioCode] || {}),
}
// console.log('stage', stage)
}
return stage;
})
//
// const allMarkers = computed(() => {
// // console.log(markerPoints.value, deviceCruise._marker.value);
@ -162,8 +275,12 @@
//
onUnmounted(() => {
if (deviceCruise) {
deviceCruise.destroy();
// if (deviceCruise) {
// deviceCruise.destroy();
// }
if (audioManager) {
audioManager.destroy();
audioManager = null;
}
});
</script>
@ -173,19 +290,20 @@
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:polygons="polygons"
:circles="circles"
:markers="markerPoints"
:polyline="polyline"
:circles="[...circles, ...errMarker.circle]"
:markers="[...markerPoints, ...markers, ...errMarker.marker]"
:polyline="[...polyline]"
:enable-satellite="true"
:enable-rotate="true"
:enable-rotate="false"
:rotate="bearing"
/>
</view>
<TrackPlayback />
<RealTimeData class="real-time-data" />
<TrackPlayback :info="{ isPaused, elapsedMs, totalTime, setCurrentTime, play, pause }" />
<TopSide />
<LeftSide :info="{ errMarker, flightDetail, stageInfo }" />
<RightSide :info="{ ...timelyData }" />
<BottomSide />
</view>
</template>
@ -219,6 +337,7 @@
width: 100%;
height: 100%;
// z-index: -1;
transform: scale(2);
}
.real-time-data {

189
src/pages/flightMap/utils.js

@ -1,5 +1,5 @@
import * as turf from '@turf/turf';
import deviceIcon from '../../assets/deviceIcon.png';
import transparentImg from '../../assets/transparent-marker.png';
// 创建同心圆多边形点集
function _createCircle({ center, redRadius, radiusDiff }) {
@ -14,7 +14,7 @@ function _createCircle({ center, redRadius, radiusDiff }) {
const innerCircle = turf.circle([center.lng, center.lat], innerRadius, options);
// 生成红色参考圆
const redCircle = turf.circle([center.lng, center.lat], redRadius, options);
// const redCircle = turf.circle([center.lng, center.lat], redRadius, options);
// 创建环形(外圆 - 内圆)
const featureCollection = turf.featureCollection([outerCircle, innerCircle]);
@ -22,7 +22,7 @@ function _createCircle({ center, redRadius, radiusDiff }) {
// 转换坐标格式为小程序地图组件要求的格式
const convertCoordinates = (coords) => {
return coords.map(coord => ({
return (coords || []).map(coord => ({
longitude: coord[0],
latitude: coord[1]
}));
@ -31,12 +31,12 @@ function _createCircle({ center, redRadius, radiusDiff }) {
// // 获取环形的坐标点集
// const ringCoords = ring.geometry.coordinates[0];
// 获取红色圆的坐标点集
const redCircleCoords = redCircle.geometry.coordinates[0];
// const redCircleCoords = redCircle.geometry.coordinates[0];
// 返回所需的点集
return {
ring: [...convertCoordinates(ring.geometry.coordinates[0]), ...convertCoordinates(ring.geometry.coordinates[1])],
redCircle: convertCoordinates(redCircleCoords),
// redCircle: convertCoordinates(redCircleCoords),
center: {
latitude: center.lat,
longitude: center.lng
@ -104,14 +104,14 @@ export function addCircleMarkers(circle1, circle2, indexMap = [6, 0, 1, 3, 4, 5,
id: index,
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
iconPath: deviceIcon,
iconPath: transparentImg,
width: 10,
height: 10,
label: {
content: String(indexMap[index] + 1),
color: '#FFFFFF',
fontSize: 20,
textStrokeWidth: 2,
fontSize: 2,
textStrokeWidth: 1,
textStrokeColor: '#000000',
anchorX: -6,
anchorY: -12,
@ -124,7 +124,7 @@ export function addCircleMarkers(circle1, circle2, indexMap = [6, 0, 1, 3, 4, 5,
}
export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, indexMap = [6, 0, 1, 3, 4, 5, 2], markerOptions = { radius: 50 }) {
export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, indexMap = [6, 0, 1, 3, 4, 5, 2], markerOptions = { radius: 5 }) {
const circle1 = _createCircle({
center: { lng: circleOptions1.center[0], lat: circleOptions1.center[1] },
redRadius: circleOptions1.radius,
@ -136,36 +136,71 @@ export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, index
radiusDiff: circleOptions2.radiusDiff
});
const polygons = [];
const circles = [];
// 添加第一个圆的环形
polygons.push({
points: circle1.ring,
fillColor: '#FFFFFF80',
strokeColor: '#00000000',
strokeWidth: 2
strokeWidth: 1
});
polygons.push({
points: circle1.redCircle,
fillColor: '#00000000',
strokeColor: '#FF0000',
strokeWidth: 2
});
// polygons.push({
// points: circle1.redCircle,
// fillColor: '#00000000',
// strokeColor: '#FF0000',
// strokeWidth: 2
// });
circles.push({
latitude: circleOptions1.center[1],
longitude: circleOptions1.center[0],
color: '#FF0000',
radius: circleOptions1.radius,
// fillColor: '#00000000',
strokeWidth: 0.8
})
circles.push({
latitude: circleOptions1.center[1],
longitude: circleOptions1.center[0],
color: '#FF0000',
radius: circleOptions1.centerWidth,
fillColor: '#FF0000',
strokeWidth: 0
})
// 添加第二个圆的环形
polygons.push({
points: circle2.ring,
fillColor: '#FFFFFF80',
strokeColor: '#00000000',
strokeWidth: 2
strokeWidth: 1
});
polygons.push({
points: circle2.redCircle,
fillColor: '#00000000',
strokeColor: '#FF0000',
strokeWidth: 2
});
// polygons.push({
// points: circle2.redCircle,
// fillColor: '#00000000',
// strokeColor: '#FF0000',
// strokeWidth: 2
// });
circles.push({
latitude: circleOptions2.center[1],
longitude: circleOptions2.center[0],
color: '#FF0000',
radius: circleOptions2.radius,
// fillColor: '#FF0000',
strokeWidth: 0.8
})
circles.push({
latitude: circleOptions2.center[1],
longitude: circleOptions2.center[0],
color: '#FF0000',
radius: circleOptions2.centerWidth,
fillColor: '#FF0000',
strokeWidth: 0
})
// 生成标记点和圆形标记
const center1 = turf.point(circleOptions1.center);
@ -199,7 +234,6 @@ export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, index
turf.midpoint(center1, center2).geometry.coordinates // 连线中点
];
const circles = [];
const markers = [];
// 生成标记点数据
@ -216,8 +250,26 @@ export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, index
longitude: markerPoint.longitude,
color: '#FF0000',
radius: markerOptions.radius,
strokeWidth: 2
strokeWidth: 0.8
});
// // 生成红色参考圆
// const redCircle = turf.circle([markerPoint.longitude, markerPoint.latitude], markerOptions.radius, {steps: 64, units: 'meters'});
// // 转换坐标格式为小程序地图组件要求的格式
// const convertCoordinates = (coords) => {
// return coords.map(coord => ({
// longitude: coord[0],
// latitude: coord[1]
// }));
// };
// // 获取红色圆的坐标点集
// const redCircleCoords = redCircle.geometry.coordinates[0];
// polygons.push({
// points: convertCoordinates(redCircleCoords),
// fillColor: '#00000000',
// strokeColor: '#FF0000',
// strokeWidth: 1
// });
}
// 添加数字标记
@ -225,17 +277,17 @@ export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, index
id: index,
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
iconPath: deviceIcon,
iconPath: transparentImg,
width: 1,
height: 1,
label: {
content: String(indexMap[index] + 1),
color: '#FFFFFF',
fontSize: 20,
textStrokeWidth: 2,
textStrokeColor: '#000000',
anchorX: -6,
anchorY: -12,
color: '#000000',
fontSize: 8,
// textStrokeWidth: 2,
// textStrokeColor: '#007fcf',
anchorX: -3,
anchorY: -6,
bgColor: '#00000000'
}
});
@ -285,74 +337,3 @@ export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, index
// this._circle2 = null;
// }
// }
///////////////
/**
* 将二维坐标数组转换为轨迹对象包含自动生成的时间戳和航向角
* @param {Array} coords 二维数组格式如 [[lng, lat], ...]
* @param {Object} [options] 配置选项
* @param {Date|string} [options.startTime] 起始时间(默认当前时间)
* @param {number} [options.interval=1000] 时间间隔(毫秒)
* @param {boolean} [options.calculateYaw=true] 是否计算航向角
* @returns {Object} 轨迹对象 {id: string, points: Array}
*/
export function convertToTrajectory(coords, options = {}) {
// 参数处理
const {
startTime = new Date(),
interval = 1000,
calculateYaw = true
} = options;
const startTimestamp = new Date(startTime).getTime();
const id = `trajectory_${startTimestamp}`;
// 核心转换逻辑
const points = coords.map(([lng, lat], index, arr) => {
// 生成时间戳
const timestamp = startTimestamp + index * interval;
// 航向角计算
let yaw = 0;
if (calculateYaw && index > 0) {
const prev = arr[index - 1];
yaw = calculateBearing(prev, [lng, lat]);
}
return {
lng,
lat,
timestamp,
yaw: parseFloat(yaw.toFixed(2)),
alt: index * 2 // 示例高度值
};
});
// 闭环处理(首尾坐标相同时)
// if (calculateYaw && points.length > 1) {
// const first = points[0];
// const last = points[points.length - 1];
// if (first.lng === last.lng && first.lat === last.lat) {
// last.yaw = calculateBearing(points[points.length - 2], first);
// }
// }
return { id, points };
}
// 航向角计算函数(单位:度)
export function calculateBearing(prev, current) {
// console.log(prev, current);
const [lng1, lat1] = prev;
const [lng2, lat2] = current;
const dLon = (lng2 - lng1) * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2 * Math.PI / 180);
const x = Math.cos(lat1 * Math.PI / 180) * Math.sin(lat2 * Math.PI / 180) -
Math.sin(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.cos(dLon);
let angle = Math.atan2(y, x);
return ((angle * 180 / Math.PI) + 360) % 360; // 转换为0-360度
}

26
src/pages/home/index.vue

@ -4,13 +4,17 @@
import TabBar from '../../components/TabBar.vue';
// import { useTaskStore } from "../../stores";
// import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { reactive, ref, onMounted, onUnmounted } from "vue";
import { RectRight } from '@nutui/icons-vue-taro'
import list from '../../assets/list.png';
import flower from '../../assets/flower.png';
import {useAuthStore} from "../../stores";
import {storeToRefs} from "pinia";
Taro.hideHomeButton();
const { isStudent } = storeToRefs(useAuthStore());
// const { getTaskList } = useTaskStore();
// const { taskList } = storeToRefs(useTaskStore());
@ -67,6 +71,13 @@
state.type = type;
state.show = true;
}
onMounted(() => {
});
onUnmounted(() => {
});
</script>
<template>
@ -119,8 +130,8 @@
<div class="round2"></div>
</div>
<div class="content">
<div class="text">本程序可查看学习过程实操记录</div>
<div class="text">每天给自己一个评价进步更快哦 </div>
<div class="text">本程序可查看{{ isStudent ? '您的' : '学生的' }}实操记录练习进度</div>
<div class="text">每天给{{ isStudent ? '自己' : '学生' }}一个评价进步更快哦 </div>
</div>
<div class="tmp"></div>
@ -129,15 +140,6 @@
<image :src="flower" class="flower" />
</div>
<div class="tip-center" v-if="false">
<div style="display: flex;">
<div>1</div><div style="flex: 1">本程序可查看学习过程实操记录</div>
</div>
<div style="display: flex;margin-top: 10px;">
<div>2</div><div style="flex: 1">每天给自己一个评价进步更快哦 </div>
</div>
</div>
<div class="bottom-text">
低空培训系统技术支持
</div>

118
src/pages/login/index.vue

@ -1,13 +1,24 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import {computed, reactive, ref, watch} from 'vue';
import Taro from "@tarojs/taro";
import { useAuthStore } from '../../stores';
import {useAuthStore, useFlightStore} from '../../stores';
import * as auth from '../../utils/auth';
import { Loading } from '@nutui/icons-vue-taro'
import { storeToRefs } from 'pinia';
// import from '../../utils/userInfo';
const { loginWithPassword, wechatAuth, updateStudentAvatar, updateMemberAvatar } = useAuthStore();
const { loginWithPassword, wechatAuth, updateStudentAvatar, updateMemberAvatar, studentLogin } = useAuthStore();
const { userInfo, isStudent } = storeToRefs(useAuthStore());
const { studentBindDrone } = useFlightStore();
const currentTab = ref('student');
const loginFn = computed(() => {
const { [currentTab.value]: Fn } = {
teacher: loginWithPassword,
student: studentLogin,
};
return Fn;
})
const showLoginDialog = ref(false);
const pageLoading = ref(false);
@ -38,21 +49,45 @@
wechatAuth({ code: loginRes.code, sceneId: loadRes.scene }).then(() => {
if (auth.getToken()) {
updataAvatar();
showLoginDialog.value = false;
if (loadRes.scene && (loadRes.scene || '').startsWith('drone_sn') && isStudent.value) {
console.log(loadRes.scene);
const droneSn = decodeURIComponent(loadRes.scene).replace("drone_sn:", "");
console.log(droneSn);
Taro.login({
success: (loginRes2) => {
studentBindDrone({
code: loginRes2.code,
droneSn,
}).then(() => {
Taro.reLaunch({ url: `/pages/studentMap/index?droneSn=${droneSn}` });
}).catch(({ msg }) => {
// console.log('eee', err);
if (msg) openToast('warn', msg);
})
},
fail: () => {
// pageLoading.value = false;
openToast('warn', '未得到微信授权Code');
}
})
} else {
Taro.reLaunch({ url: '/pages/home/index' });
// openToast('success', ``);
return;
}
if (loadRes.scene && (`${loadRes.scene}`.length > 6)) {
showLoginDialog.value = true;
return;
// showLoginDialog.value = false;
// openToast('success', ``);
// return;
}
if (!loadRes.scene && showLoginDialog.value) {
openToast('warn', '未查询到用户,请先在地面站绑定');
// if (loadRes.scene && (`${loadRes.scene}`.length > 6)) {
// showLoginDialog.value = true;
return;
}
console.log('userInfo', userInfo.value);
// return;
// }
// if (!loadRes.scene && showLoginDialog.value) {
// openToast('warn', '');
// // showLoginDialog.value = true;
// return;
// }
// console.log('userInfo', userInfo.value);
}).catch(({ msg }) => {
// console.log('eee', err);
if (msg) openToast('warn', msg);
@ -94,6 +129,7 @@
const formRef = ref();
const INIT_FORM_DATA = {
sn: '',
phone: '',
password: '',
};
@ -104,14 +140,23 @@
formRef.value?.validate().then(({ valid }) => {
if (!valid) return;
loading.value = true;
loginWithPassword(formData.value).then(() => {
Taro.login({
success: (loginRes) => {
loginFn.value({ ...formData.value, code: loginRes.code }).then(() => {
updataAvatar();
Taro.reLaunch({ url: '/pages/own/index' });
Taro.reLaunch({ url: '/pages/home/index' });
// Taro.reLaunch({ url: '/pages/supervisionMap/index' });
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
},
fail: () => {
loading.value = false;
openToast('warn', '未得到微信授权Code');
}
})
})
}
@ -127,15 +172,22 @@
}
}
const rules = ref({
const rules = computed(() => ({
...(currentTab.value === 'teacher' ? {
phone: [
{ required: true, message: '请填写手机号' },
{ validator: asyncValidator, message: '电话格式不正确' }
],
]
} : {}),
...(currentTab.value === 'student' ? {
sn: [
{ required: true, message: '请填写学号' },
]
} : {}),
password: [
{ required: true, message: '请填写密码' },
],
})
}));
const state = reactive({
msg: '错误提示',
@ -157,6 +209,26 @@
<template>
<div :class="s.root">
<div class="title">低空培训系统</div>
<nut-tabs v-model="currentTab">
<nut-tab-pane title="学员" pane-key="student">
<nut-form
ref="formRef"
label-position="top"
star-position="left"
:model-value="formData"
:rules="rules">
<nut-form-item label="学号" prop="sn">
<nut-input v-model="formData.sn" placeholder="请输入手机号" />
</nut-form-item>
<nut-form-item label="密码" prop="password">
<nut-input v-model="formData.password" placeholder="请输入密码" type="password" />
</nut-form-item>
<nut-form-item>
<nut-button :loading="loading" type="info" size="large" @click="submit">登录</nut-button>
</nut-form-item>
</nut-form>
</nut-tab-pane>
<nut-tab-pane title="机构人员" pane-key="teacher">
<nut-form
ref="formRef"
label-position="top"
@ -173,6 +245,8 @@
<nut-button :loading="loading" type="info" size="large" @click="submit">登录</nut-button>
</nut-form-item>
</nut-form>
</nut-tab-pane>
</nut-tabs>
<div class="bottom-end">低空培训系统技术支持</div>
</div>
@ -242,12 +316,16 @@
font-weight: bold;
}
.nut-tab-pane {
padding: 0;
}
.nut-form {
// margin-top: 200px;
border-radius: 16px;
display: block;
width: 100%;
transform: translateY(-20%);
//transform: translateY(-20%);
// margin-top: 200px;
.nut-cell-group__wrap {

3
src/pages/own/index.vue

@ -111,7 +111,8 @@
</div>
<nut-button shape="square" class="btn" type="info" size="large" open-type="chooseAvatar" @chooseavatar="onChooseAvatar" :loading="loading" @error="onCancel">更换头像</nut-button>
<nut-button shape="square" type="primary" v-if="!isStudent && !isTeacher" class="btn" size="large" @click="isOpened = true">退出</nut-button>
<!-- <nut-button shape="square" type="primary" v-if="!isStudent && !isTeacher" class="btn" size="large" @click="isOpened = true">退出</nut-button>-->
<nut-button shape="square" type="primary" v-if="false" class="btn" size="large" @click="isOpened = true">退出</nut-button>
</div>
<nut-dialog

6
src/pages/returnTripMap/utils.js

@ -1,8 +1,6 @@
import * as turf from '@turf/turf';
import homeBg from '../../assets/home-marker.png';
import Taro from "@tarojs/taro";
const deviceInfo = Taro.getDeviceInfo()
import * as Taro from "@tarojs/taro";
// 创建同心圆多边形点集
export function createCircle({ center, innerRadius, outerRadius }) {
@ -41,7 +39,7 @@ export function createCircle({ center, innerRadius, outerRadius }) {
// 初始化返航界面配置
export function initReturnTripConfig(centerPoint, innerRadius = 50, outerRadius = 100) {
const markerOptions = {
...(`${deviceInfo?.system || ''}`.includes('iOS') ? {
...(`${Taro.getDeviceInfo()?.system || ''}`.includes('iOS') ? {
color: '#FFFFFF',
fontSize: 12,
// anchorX: -5,

93
src/pages/studentMap/BottomSide.vue

@ -0,0 +1,93 @@
<script setup>
import { computed, ref } from 'vue';
import { IconFont } from "@nutui/icons-vue-taro";
// import { toFixed } from '../../../utils/helpers';
import deviceCruise from '../../core/useDeviceCruise';
import {rotate} from "mathjs";
// import deviceIcon from '../../../assets/deviceIcon.png'
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="uav4" :style="{ color: info?.droneOnLine ? 'snow' : 'red', 'font-size': '24px' }" />
<div class="value" v-if="info?.connectLoading">连接中...</div>
<div class="value" v-else :style="{ color: info?.droneOnLine ? 'snow' : 'red' }">{{ info?.droneOnLine ? '已连接飞机' : '飞机失联..' }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="battery" />
<div class="value">{{ info?.voltage || '-' }} v</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="measure" :style="{ transform: 'rotate(90deg)' }" />
<div class="value">{{ info?.height || '-' }} m</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="compass" :style="{ transform: 'rotate(45deg)' }"/>
<div class="value">{{ info?.yaw || '-' }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="satellite" />
<div class="value">{{ info?.satellite || '-' }} {{ info?.fixTypeLabel || '-'}}</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 100px;
left: 100px;
//top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: snow;
font-size: 14px;
font-weight: bold;
//width: 100px;
//min-width: 100px;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-around;
gap: 10px;
padding: 10px;
//color: black;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.item {
display: flex;
align-items: center;
font-family: monospace;
//color: white;
.iconfont {
width: 10px;
height: 10px;
font-size: 16px;
//color: #4CAF50;
}
.value {
margin-left: 8px;
font-size: 12px;
}
}
}
}
</style>

236
src/pages/studentMap/LeftSide.vue

@ -0,0 +1,236 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
import { TIP_TEXT } from '../../config/tipTextMap';
import Taro from "@tarojs/taro";
const isVisible = ref(true);
// const info = computed(() => ({}));
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
function onBack() {
Taro.reLaunch({ url: '/pages/home/index' });
}
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="box">
<div class="countdown">
<div class="title">
<IconFont font-class-name="iconfont" class-prefix="icon" name="time" />
</div>
<div class="value">{{ toFixed(info.timeTicker || 0, 1) }}</div>
</div>
</div>
<div class="box box2">
<div class="data-item">
<div class="text" v-if="false">切线速度</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="tangent-speed" />
</div>
<div class="value-wrapper">{{ toFixed(info?.speed || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">航向偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="tangent-angle" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angle || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">高度偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="offset-v" />
</div>
<div class="value-wrapper">{{ toFixed(info?.vertical || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">水平偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="offset-h" />
</div>
<div class="value-wrapper">{{ toFixed(info?.horizontal || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">角速度</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="angle-speed" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angleSpeed || 0, 2) }}</div>
</div>
</div>
<div class="box box3">
<div class="data-item" v-for="(item, index) in (info?.errPoints || [])" :key="index">
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" :name="TIP_TEXT[item.audioCode]?.icon" />
</div>
<div class="value-wrapper">{{ toFixed(item.value || 0, 2) }}</div>
</div>
</div>
<div class="box box4" @click="onBack">
<span>返回首页</span>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
// padding: 8px;
color: white;
font-size: 11px;
width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
:global {
.canvas-bg {
width: 100%;
height: 80%;
position: absolute;
//top: 0;
//right: 0;
//left: 0;
//bottom: 20px;
}
.nav {
position: absolute;
right: -20px;
top: 6px;
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 0 4px 4px 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
color: #7FFF00;
border-bottom: 1px solid rgb(255, 255, 255);
// padding: 4px 8px;
.countdown {
display: flex;
align-items: center;
padding: 0 8px;
//justify-content: center;
//justify-content: ;
.title {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
.value {
font-size: 24px;
font-weight: bold;
text-align: center;
padding: 5px;
}
}
.data-item {
display: flex;
align-items: center;
justify-content: center;
// gap: 12px;
// padding: 5px;
.text {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
}
.icon-wrapper {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
.value-wrapper {
flex: 1;
font-size: 18px;
font-weight: 500;
text-align: left;
padding-left: 8px;
}
}
}
.box2 {
padding: 8px;
}
.box3 {
flex: 1;
color: red;
padding: 4px 8px;
.icon-wrapper {
background: rgba(255, 0, 0, 0.1) !important;
}
}
.box4 {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
//height: 40px;
color: white;
//text-align: center;
border-bottom: none;
}
}
}
</style>

124
src/pages/studentMap/ResultModal.vue

@ -0,0 +1,124 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false
},
name: {
type: String,
default: '张三'
},
uavId: {
type: String,
default: 'UAV-001'
},
isPassed: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:show']);
const handleClose = () => {
emit('update:show', false);
};
</script>
<template>
<view v-show="show" :class="s.root">
<view class="modal-content">
<view class="close-btn" @tap="handleClose">×</view>
<view class="result-status" :class="{ 'pass': isPassed }">
{{ isPassed ? '通过' : '未通过' }}
</view>
<view class="info-item">
<text class="label">姓名</text>
<text class="value">{{ name }}</text>
</view>
<view class="info-item">
<text class="label">飞机编号</text>
<text class="value">{{ uavId }}</text>
</view>
</view>
</view>
</template>
<style lang="less" module="s">
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
:global{
.modal-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 12px;
min-width: 280px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
color: #666;
}
}
.result-status {
font-size: 20px;
font-weight: bold;
text-align: center;
margin-bottom: 16px;
color: #ff4d4f;
&.pass {
color: #52c41a;
}
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
.label {
color: #838383;
margin-right: 8px;
font-size: 14px;
}
.value {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
</style>

146
src/pages/studentMap/RightSide.vue

@ -0,0 +1,146 @@
<script setup>
import {computed, onUnmounted, ref} from 'vue';
// import { IconFont } from "@nutui/icons-vue-taro";
// import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../core/deviceCruise';
import droneImg from '../../assets/droneImg.png';
import { storeToRefs } from 'pinia';
import {useAuthStore} from "../../stores";
import dayjs from 'dayjs';
const { userInfo } = storeToRefs(useAuthStore());
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
const weightClasses = {
small: "小型",
medium: "中型",
large: "大型",
};
const currentTime = ref(dayjs().format('HH:mm'));
let timer = setInterval(() => {
currentTime.value = dayjs().format('HH:mm');
}, 2000);
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="box box1">
<div class="data-item">{{ userInfo?.name || '-' }}</div>
<div class="data-item" style="font-size: 10px">{{ userInfo?.sn || '-' }}</div>
<div class="data-item">{{ userInfo?.licenseGrade?.alias }}{{ userInfo?.licenseGrade?.name }}</div>
<div class="data-item" style="font-size: 11px;">多旋翼-{{ weightClasses[userInfo?.weightClass] || '-' }}无人机</div>
<div class="data-item">
<image class="img" :src="droneImg" />
</div>
</div>
<div class="box box2">
{{ info?.text }}
</div>
<div class="box box3">
{{ currentTime }}
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 11px;
width: 100px;
//min-width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
color: white;
border-bottom: 1px solid rgb(255, 255, 255);
padding: 4px 8px;
.data-item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
.icon-wrapper {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
}
.value-wrapper {
flex: 1;
font-size: 18px;
font-weight: 500;
text-align: left;
padding-left: 8px;
}
}
}
.box1 {
.img {
width: 20px;
height: 20px;
}
}
.box2 {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 16px;
}
.box3 {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
//height: 40px;
color: white;
//text-align: center;
border-bottom: none;
}
}
}
</style>

118
src/pages/studentMap/geo.js

@ -0,0 +1,118 @@
/**
* 地理空间函数库
*/
import * as math from 'mathjs';
import * as turf from '@turf/turf';
/**
* 将两个点连成一个向量并确保落在14象限
* @param point1
* @param point2
* @returns {Vector}
*/
export function pointToVector(point1, point2) {
const vec = math.subtract(point2, point1);
return vec[0] < 0 ? math.multiply(-1, vec) : vec;
}
/**
* 弧度2角度
* @param radians
* @returns {number}
*/
export function radToDeg(radians) {
return math.multiply(radians, math.divide(180, math.pi));
}
/**
* 计算二维向量与x轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithXAxis(vector) {
const [x] = vector;
const magnitude = math.norm(vector);
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = x / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算二维向量与 y 轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithYAxis(vector) {
const [, y] = vector;
const magnitude = math.norm(vector); // 计算向量的模长 ||v||
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = y / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = math.max(-1, math.min(1, cosTheta));
// 计算夹角 theta
return math.acos(safeCosTheta);
}
/**
* 计算两个二维向量之间的夹角弧度
* @param {number[]} vectorA - 第一个二维向量 [x1, y1]
* @param {number[]} vectorB - 第二个二维向量 [x2, y2]
* @returns {number} 夹角的弧度值
*/
export function angleBetweenVectors(vectorA, vectorB) {
const dotProduct = math.dot(vectorA, vectorB);
const magnitudeA = math.norm(vectorA);
const magnitudeB = math.norm(vectorB);
if (magnitudeA === 0 || magnitudeB === 0) {
throw new Error('其中一个向量是零向量,夹角未定义');
}
const cosTheta = dotProduct / (magnitudeA * magnitudeB);
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算两个坐标点的中点
* @param point1
* @param point2
* @returns {number[]}
*/
export function midPoint(point1, point2) {
const [x1, y1] = point1;
const [x2, y2] = point2;
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [+x.toFixed(8), +y.toFixed(8)];
}
/**
* 生成圆形路径
* @param center 圆心[lng, lat]
* @param radius 半径单位
* @param steps 分段数
* @returns {any}
*/
export function genCirclePath(center, radius, steps = 64) {
const geojson = turf.circle(center, radius / 1000, {
steps,
units: 'kilometers',
});
const [coords] = turf.getCoords(geojson);
return coords;
}

5
src/pages/studentMap/index.config.js

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '学生实时飞行',
navigationStyle: 'custom',
pageOrientation: 'landscape'
})

402
src/pages/studentMap/index.vue

@ -0,0 +1,402 @@
<script setup>
import {onMounted, ref, onUnmounted, reactive, watch} from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import LeftSide from './LeftSide.vue';
import RightSide from './RightSide.vue';
import BottomSide from './BottomSide.vue';
import { creatEightShaped } from '../flightMap/utils';
import ResultModal from './ResultModal.vue';
import {useAirFieldsStore, useStandardStore, useSupervisionStore} from "../../stores";
import { useDroneMarker } from "./useDroneMarker";
import { storeToRefs } from 'pinia';
import { GPS2GCJ } from '../../utils/helpers';
const { markers, rotate, polyline, errMarker, errCircle, errPoints } = useDroneMarker();
const { getExamList, getEnvList } = useStandardStore()
const { wsDroneData } = useSupervisionStore();
const { position, attitude, battery, gps, tip, deviation, connectLoading, connectDrone, stageInfo, errorInfo, droneOnLine } = storeToRefs(useSupervisionStore());
const { getAirFieldsOfStudent, getAirFieldsDetail } = useAirFieldsStore();
const { params } = Taro.useRouter();
console.log('params', params);
// const droneSn = ref()
//
let mapContext = null;
const show = ref(true);
const firstMarker = ref(true);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = false;
// setMapInstance(mapContext);
init();
initAudio();
});
onUnmounted(() => {
if (audioManager) {
audioManager.destroy();
audioManager = null;
}
if (ws) {
confirmClose.value = true;
ws.close();
ws = null;
}
// eventBus.off('show-teaching-track-replay')
})
//
const polygons = ref([]);
//
const circles = ref([]);
//
const markerPoints = ref([]);
//
const bearing = ref(0);
// //
// const polyline = ref([{
// points: trackData.map(coord => ({
// latitude: coord[1],
// longitude: coord[0]
// })),
// color: '#008000',
// width: 4,
// arrowLine: true
// }]);
// let sum = 0;
let ws;
const confirmClose = ref(false);
function connect() {
if (!params?.droneSn) {
openToast('warn', '未获取到飞机编号,请使用微信重新扫码。');
return;
}
wsDroneData(params?.droneSn).then(wsTask => {
ws = wsTask;
wsTask.onMessage(() => {
// if (firstMarker.value && position.value?.lng) {
// initDevice({ ...position.value, ...attitude.value }, bearing.value);
// firstMarker.value = false;
// } else {
// moveDevice({ ...position.value, ...attitude.value }, bearing.value)
// }
});
wsTask.onError(() => {
// clearDevice()
// setTimeout(() => {
// sum += 1;
// connect();
// }, 3000);
ws.close()
});
wsTask.onClose(() => {
// initDevice();
if (confirmClose.value) return;
ws.close();
setTimeout(() => {
// sum += 1;
connect();
}, 3000);
})
});
}
const enableRotate = ref(false)
function init() {
//
Promise.all([
getExamList(),
getEnvList(),
// getAirFieldsDetail('9' || '10'),
getAirFieldsOfStudent(),
]).then(([examlist, envlist, airFieldsData]) => {
console.log('air', airFieldsData);
const airfield = airFieldsData?.data || {};
const { records = [] } = examlist || {}
const envItem = (envlist || []).find((item) => item.id === airfield.envGradeId) || {}
const standardData = records.find(
(item) =>
item.name === '8字圆圈半径' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const standardDiffData = records.find(
(item) =>
item.name === '8字水平偏差阈值' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const point3standardData = records.find(
(item) =>
item.name === '点3中心筒范围-内' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
);
const center1 = GPS2GCJ([airfield.circle1Lng, airfield.circle1Lat]);
const center2 = GPS2GCJ([airfield.circle2Lng, airfield.circle2Lat]);
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped(
{
center: center1,
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
{
center: center2,
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
[6, 0, 1, 3, 4, 5, 2],
{ radius: point3standardData.value },
);
//
polygons.value = shapePolygons;
setTimeout(() => {
circles.value = shapeCircles;
markerPoints.value = shapeMarkers;
}, 500);
//
mapContext.includePoints({
points: [
{ latitude: center1[1], longitude: center1[0] },
{ latitude: center2[1], longitude: center2[0] }
],
// padding: [130, 50, 130, 10],
success: (res) => {
setTimeout(() => {
//
const angle = turf.bearing(center1, center2);
// 使
rotate.value = 90 - angle;
bearing.value = 90 - angle;
connect();
}, 500);
// console.log('', res);
},
fail: (err) => {
enableRotate.value = true;
console.error('地图视野设置失败', err);
}
});
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
let audioManager;
const temTip = ref([]);
const tmp = ref({});
watch(tip, (val) => {
if (!val?.text) return;
temTip.value.push(val);
if (temTip.value.length === 1) {
tmp.value = val;
if (audioManager) {
audioManager.src = tmp.value.fileUrl;
audioManager.play();
}
}
});
function initAudio() {
audioManager = Taro.createInnerAudioContext();
// audioManager.onPlay(() => {
// console.log('')
// openToast();
// })
audioManager.onError((res) => {
console.error('音频错误:', res.errMsg, res.errCode);
});
audioManager.onEnded(() => {
temTip.value.splice(0, 1);
if (temTip.value.length === 0) return;
tmp.value = temTip.value[0];
audioManager.src = tmp.value.fileUrl;
audioManager.play();
});
// audioManager.value.title = '';
// console.log('tt', tt);
// audioManager.value.src = 'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745814185096733360.mp3';
// audioManager.value.play();
// setTimeout(() => {
// audioManager.value.src = tt2;
// }, 5000)
// audioManager.value.onPlay(() => {
// console.log('');
// });
//
// audioManager.value.onError((res) => {
// console.log('', res);
// //
// });
//
// audioManager.value.onEnded(() => {
// console.log('');
// });
}
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
// title: '',
// bottom: '',
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:markers="[...markerPoints, ...markers, ...errMarker]"
:polygons="polygons"
:polyline="[...polyline]"
:scale="20"
:circles="[...circles, ...errCircle]"
:enable-rotate="enableRotate"
:rotate="bearing"
:enable-satellite="true"
:show-compass="true"
/>
</view>
<LeftSide :info="{ ...deviation, ...stageInfo, ...errorInfo, errPoints }" />
<RightSide :info="{ text: tmp?.text || '' }" />
<BottomSide :info="{ ...position, ...attitude, ...gps, ...battery, connectLoading, connectDrone, droneOnLine }"/>
</view>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
<ResultModal v-model:show="show" />
</template>
<style lang="less" module="s">
page {
height: 100%;
overflow: hidden;
}
.root {
position: relative;
width: 100%;
height: 100%;
:global {
.mapBox {
width: 100%;
height: 100%;
position: relative;
#map {
width: 100%;
height: 100%;
transform: scale(2);
}
.exam-result-modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.modal-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 12px;
min-width: 280px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
color: #666;
}
}
.result-status {
font-size: 20px;
font-weight: bold;
text-align: center;
margin-bottom: 16px;
color: #ff4d4f;
&.pass {
color: #52c41a;
}
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
.label {
color: #838383;
margin-right: 8px;
font-size: 14px;
}
.value {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
.real-time-data {
z-index: 1;
}
}
}
</style>

224
src/pages/studentMap/useConnector.js

@ -0,0 +1,224 @@
import { createGlobalState, useWebSocket } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import * as turf from '@turf/turf';
import gcoord from 'gcoord';
import * as urls from '../config/urls';
import * as geo from '../utils/geo';
import { GPS_FIX_TYPE, GPS_FIX_TYPE2 } from '../config/gpsFixTypeMap';
import { FLY_MODE } from '../config/flyModeMap';
import { FC_SYSTEM_STATUS } from '../config/fcSystemStatus';
import { ERRORS_COUNT_1, ERRORS_COUNT_2, ERRORS_COUNT_3 } from '../config/errorMap';
import { useAnnouncer } from './useAnnouncer';
const announcer = useAnnouncer();
export const useConnector = createGlobalState(() => {
const { ws, status, data, send, open, close } = useWebSocket(urls.WS_URL, {
immediate: false,
heartbeat: {
interval: 3000,
},
});
watch(data, (val) => {
if (val === 'ping') {
send('pong');
}
});
const isConnecting = computed(() => status.value === 'CONNECTING');
const isConnected = computed(() => status.value === 'OPEN');
const time = ref({});
const battery = ref({});
const gps = ref({});
const position = ref({});
const attitude = ref({});
const home = ref({});
const homeAngle = ref(0);
const modeName = ref('N/A');
const sysStatus = ref('N/A');
const extra = ref({});
const info = computed(() => {
let result = data.value || '';
if (!`${data.value}`.startsWith('{')) {
return {};
}
try {
result = JSON.parse(result);
} catch (e) {
return {};
}
return result;
});
// 闲置计时器(闲置时做些动作)
let idleTimer = null;
watch(info, (val) => {
// info有变化时,清掉上一个计时器
if (idleTimer) clearTimeout(idleTimer);
// 电池信息、故障信息
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#22-%E7%B3%BB%E7%BB%9F%E7%8A%B6%E6%80%81-sys_status
if (val?.msgId === 1) {
const { VoltageBattery, CurrentBattery, BatteryRemaining, ErrorsCount1, ErrorsCount2, ErrorsCount3 } = val.data || {};
const voltage = +(VoltageBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cV即厘伏(mavlink文档中写的是mV即毫伏)
const current = +(CurrentBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cA即厘安
const remaining = BatteryRemaining >= 0 ? BatteryRemaining : null; // 单位:%
battery.value = { voltage, current, remaining };
// todo 临时
if (ErrorsCount1) {
const { [ErrorsCount1]: errorContent } = ERRORS_COUNT_1;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount2) {
const { [ErrorsCount2]: errorContent } = ERRORS_COUNT_2;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount3) {
const { [ErrorsCount3]: errorContent } = ERRORS_COUNT_3;
announcer.alarmThrottle(errorContent);
}
}
// 系统时间
// https://mavlink.io/en/messages/common.html#SYSTEM_TIME
if (val?.msgId === 2) {
const { TimeUnixUsec, TimeBootMs } = val.data || {};
const timestamp = parseInt(TimeUnixUsec / 1e3, 10); // to毫秒
const boot = TimeBootMs; // 毫秒
time.value = { timestamp, boot };
}
// GPS(with RTK)
// https://mavlink.io/en/messages/common.html#GPS2_RAW
if (val?.msgId === 124) {
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const fixTypeLabel = GPS_FIX_TYPE2.get(fixType);
gps.value = { fixType, fixTypeLabel, satellite };
}
// GPS(普通GPS)
// https://mavlink.io/en/messages/common.html#GPS_INPUT
if (val?.msgId === 232) {
if ('fixType' in gps.value) return;
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const { [fixType]: fixTypeLabel } = GPS_FIX_TYPE;
gps.value = { fixType, fixTypeLabel, satellite };
}
// 飞机位置
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#25-%E8%9E%8D%E5%90%88%E7%BB%8F%E7%BA%AC%E5%BA%A6%E9%80%9F%E5%BA%A6-global_position_int
if (val?.msgId === 33) {
const { Lon, Lat, Alt, RelativeAlt, Vx, Vy, Vz } = val.data || {};
const [lng, lat] = gcoord.transform([Lon / 1e7, Lat / 1e7], gcoord.WGS84, gcoord.GCJ02);
const alt = Alt / 1e3; // 源值毫米
const height = RelativeAlt / 1e3; // 源值毫米
const sx = (+Vx || 0) / 1e2; // 向北速度分量(源值厘米)
const sy = (+Vy || 0) / 1e2; // 向东速度分量(源值厘米)
const sz = (+Vz || 0) / 1e2; // 向下速度分量(源值厘米)
const hSpeed = Math.hypot(sx, sy);
const vSpeed = Math.abs(sz);
const { lng: hLng, lat: hLat } = home.value || {};
const homeDist = hLng !== undefined ? turf.distance([lng, lat], [hLng, hLat]) * 1e3 : null; // distance单位是km
position.value = { lng, lat, alt, height, hSpeed, vSpeed, sx, sy, sz, homeDist };
}
// 飞机姿态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#26-%E9%A3%9E%E6%9C%BA%E5%A7%BF%E6%80%81%E8%A7%92%E9%80%9F%E5%BA%A6-attitude
if (val?.msgId === 30) {
const { Yaw, Pitch, Roll } = val.data || {};
const yaw = +geo.radToDeg(Yaw || 0).toFixed(1);
const pitch = +geo.radToDeg(Pitch || 0).toFixed(1);
const roll = +geo.radToDeg(Roll || 0).toFixed(1);
attitude.value = { yaw, pitch, roll };
}
// 油门
// https://mavlink.io/en/messages/common.html#VFR_HUD
if (val?.msgId === 74) {
const { Throttle } = val.data || {};
extra.value = { throttle: Throttle };
}
// home点
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#23-home%E7%82%B9-home_position
if (val?.msgId === 242) {
const { Longitude, Latitude } = val.data || {};
let lng = +(Longitude / 1e7).toFixed(7);
let lat = +(Latitude / 1e7).toFixed(7);
[lng, lat] = [lat, lng]; // todo 临时颠倒一下
home.value = { lng, lat };
}
// 飞行模式、解锁状态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#110-%E8%87%AA%E5%AE%9A%E4%B9%89%E9%A3%9E%E8%A1%8C%E6%A8%A1%E5%BC%8F-vkfly_custom_mode
// https://mavlink.io/en/messages/common.html#HEARTBEAT
if (val?.msgId === 0) {
const { CustomMode, SystemStatus } = val.data || {};
const { [CustomMode]: label } = FLY_MODE;
modeName.value = label || 'N/A';
sysStatus.value = FC_SYSTEM_STATUS.get(SystemStatus) || 'N/A';
}
// 1秒后,若info没有新的变化,则清空这些数据
idleTimer = setTimeout(() => {
time.value = {};
battery.value = {};
gps.value = {};
position.value = {};
attitude.value = {};
home.value = {};
modeName.value = 'N/A';
sysStatus.value = 'N/A';
}, 1000);
});
// 动态计算机头与home点的夹角
watch([position, attitude], () => {
const { lng: hLng, lat: hLat } = home.value || {};
if (hLng === undefined) {
homeAngle.value = 0;
return;
}
const { lng: dLng, lat: dLat } = position.value || {};
const { yaw } = attitude.value || {};
if (dLng === undefined || yaw === undefined) return;
const [lng, lat] = [hLng - dLng, hLat - dLat]; // 以飞机为起点,home为终点的向量
// 不能是0向量(飞机与home点完全重合时,则令机头始终指向home)
if (!lng && !lat) {
homeAngle.value = 0;
return;
}
const rad = geo.angleWithYAxis([lng, lat]); // 与(0,1)这个单位向量的夹角
const deg = geo.radToDeg(rad) * (lng >= 0 ? 1 : -1); // 通过x轴正负来决定角度正负
homeAngle.value = +(deg - yaw).toFixed(2) || 0;
});
return {
ws,
isConnecting,
isConnected,
time,
battery,
gps,
position,
attitude,
home,
homeAngle,
modeName,
sysStatus,
extra,
send,
connect: open,
reconnect: open,
close,
};
});
export default null;

190
src/pages/studentMap/useDroneMarker.js

@ -0,0 +1,190 @@
import {computed, onMounted, ref, watch} from 'vue';
import deviceIcon from "../../assets/droneImg.png";
import droneDisImg from '../../assets/droneDisImg.png';
import Taro from "@tarojs/taro";
import {storeToRefs} from "pinia";
import {useSupervisionStore} from "../../stores";
import transparentImg from "../../assets/transparent-marker.png";
export function useDroneMarker() {
let mapContext;
const { position, attitude, connectDrone, droneOnLine, errorInfo } = storeToRefs(useSupervisionStore());
const rotate = ref(0);
const errPoints = ref([])
watch(errorInfo, (nv) => {
if (nv?.timestamp) {
console.log('nv', nv);
errPoints.value.push({ ...nv });
}
})
const errMarker = computed(() => {
return errPoints.value.map(((item, index) => {
return {
id: 1e5 + index,
latitude: item.lat,
longitude: item.lng,
iconPath: transparentImg,
width: 1,
height: 1,
label: {
content: String(index + 1),
color: '#ff0000',
fontSize: 8,
// textStrokeWidth: 2,
// textStrokeColor: '#007fcf',
anchorX: 3,
anchorY: -6,
bgColor: '#00000000'
}
}
}));
})
const errCircle = computed(() => {
return errPoints.value.map(((item, index) => {
return {
latitude: item.lat,
longitude: item.lng,
color: '#FF0000',
radius: 0.1,
fillColor: '#FF0000',
strokeWidth: 0
}
}));
})
const path = ref([]);
watch(position, (nv) => {
if (nv?.lng) {
path.value.push({
latitude: nv?.lat,
longitude: nv?.lng,
});
}
})
const polyline = computed(() => {
if (path.value.length < 2) {
return [];
}
return [{
points: path.value || [],
color: '#008000',
width: 0.8,
}]
});
const markers = computed(() => {
if (!Object.keys(position.value).length || !mapContext) return [];
return [{
id: 1e7,
iconPath: droneOnLine.value ? deviceIcon : droneDisImg,
width: 18,
height: 18,
anchor: { x: 0.5, y: 0.5 },
latitude: position.value?.lat,
longitude: position.value?.lng,
rotate: attitude.value?.yaw + rotate.value,
}]
})
onMounted(() => {
mapContext = Taro.createMapContext('map');
})
let realTimePoint = [];
let lastPolylineUpdateTime = 0; // 添加最后更新时间记录
// const polyline = ref([]);
function initDevice(point, mapRotate = 0) {
// if (!point) {
// markers.value = undefined;
// return;
// }
if (!point.lng || !point.lat) {
markers.value = undefined;
return;
}
const { lng, lat, yaw } = point;
markers.value = {
...markerConfig,
latitude: lat,
longitude: lng,
rotate: yaw + mapRotate,
};
realTimePoint.push(point);
// 控制轨迹线更新频率为每秒一次
const now = Date.now();
if (now - lastPolylineUpdateTime >= 1000) { // 1000ms = 1秒
if (realTimePoint.length >= 3) {
polyline.value = [{
points: realTimePoint.map(item => ({
latitude: item.lat,
longitude: item.lng,
})),
color: '#008000',
width: 0.8,
}];
}
lastPolylineUpdateTime = now;
}
}
function clearDevice() {
markers.value = undefined;
polyline.value = undefined;
realTimePoint = [];
lastPolylineUpdateTime = 0; // 重置最后更新时间
}
// function moveDevice(point, mapRotate = 0) {
// const { lng, lat, yaw } = point;
//
// map.translateMarker({
// markerId,
// destination: {
// longitude: lng,
// latitude: lat,
// },
// autoRotate: false,
// duration: 1,
// rotate: yaw + mapRotate,
// moveWithRotate: true,
// animationEnd: () => {
// // this._that.timelyData = { ...nextData };
// // this._that.currentIndex += 1;
// // if (this._that.isPlaying) {
// // this._that.elapsedMs += (duration * this._that.speedRate);
// // if (this._that.elapsedMs >= this._that.totalTime) {
// // this._that.handleStop();
// // return;
// // }
// // this._that._renderDevice();
// // }
// }
// });
// }
// const polyine = ref();
// function renderTrack(points = []) {
//
// }
return {
// initDevice,
// moveDevice,
markers,
clearDevice,
polyline,
rotate,
errMarker,
errCircle,
errPoints,
}
}

188
src/pages/supervision/index.vue

@ -1,132 +1,81 @@
<script setup>
import { ref } from 'vue';
import {onMounted, onUnmounted, ref} from 'vue';
import Taro from '@tarojs/taro';
import deviceIcon from '../../assets/deviceIcon.png'
// import deviceIcon from '../../assets/deviceIcon.png'
import TabBar from '../../components/TabBar.vue'
import {useFlightStore} from "../../stores";
import {storeToRefs} from "pinia";
// import { storeToRefs } from 'pinia';
// import { useSupervisionStore } from '../../stores';
Taro.hideHomeButton();
//
const fieldList = ref([
{
recordId: 270424022978560,
studentId: 7,
studentName: '阿亮',
teacherId: 27,
teacherName: '侯老师',
droneId: 42,
droneSn: 'DJI-001',
airfield: {
id: 3,
name: '示范飞行场地2',
imageUrl: deviceIcon
},
details: [
{
recordId: 270424022978560,
happen_time: '2025-03-04 11:16:47',
phase: '悬停',
audioCode: 'welcome',
isError: true,
errorDesc: '未按指定高度悬停'
}
],
failTimes: 1,
isPass: false,
startTime: '2025-03-05 17:19:25',
endTime: '2025-03-05 17:19:35',
inProgress: false
},
{
recordId: 270424022978561,
studentId: 8,
studentName: '小明',
teacherId: 28,
teacherName: '张老师',
droneId: 43,
droneSn: 'DJI-002',
airfield: {
id: 4,
name: '高级训练场',
imageUrl: deviceIcon
},
details: [],
failTimes: 0,
isPass: true,
startTime: '2025-03-05 18:00:00',
endTime: '2025-03-05 18:15:00',
inProgress: false
},
{
recordId: 270424022978562,
studentId: 9,
studentName: '小红',
teacherId: 29,
teacherName: '王老师',
droneId: 44,
droneSn: 'DJI-003',
airfield: {
id: 5,
name: '初级训练场',
imageUrl: deviceIcon
},
details: [
{
recordId: 270424022978562,
happen_time: '2025-03-05 19:10:30',
phase: '起飞',
audioCode: 'takeoff',
isError: true,
errorDesc: '起飞速度过快'
},
{
recordId: 270424022978562,
happen_time: '2025-03-05 19:12:00',
phase: '降落',
audioCode: 'landing',
isError: true,
errorDesc: '降落点偏离指定位置'
}
],
failTimes: 2,
isPass: false,
startTime: '2025-03-05 19:10:00',
endTime: '2025-03-05 19:15:00',
inProgress: true
}
]);
const { getRecordList } = useFlightStore();
// const { flightList, flightExtra, flightQueries } = storeToRefs(useFlightStore());
// API
// const { getFieldList } = useSupervisionStore();
// const { fieldList } = storeToRefs(useSupervisionStore());
const list = ref([]);
let timer;
Taro.useDidShow(() => {
// 使API
// getFieldList().catch(err => {
// Taro.showToast({
// title: err?.description || '',
// icon: 'none'
// });
// });
timer = setInterval(() => {
getList();
}, 3000);
});
function onRefresh() {
// 使API
// return getFieldList().finally(() => {
// Taro.stopPullDownRefresh();
// });
Taro.stopPullDownRefresh();
}
Taro.useDidHide(() => {
if (timer) {
clearInterval(timer);
}
})
Taro.usePullDownRefresh(() => {
onRefresh();
onMounted(() => {
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
function onNavTo(id) {
getList();
function getList() {
getRecordList().then(data => {
list.value = [...data];
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
}
// API
// const { getFieldList } = useSupervisionStore();
// const { fieldList } = storeToRefs(useSupervisionStore());
// Taro.useDidShow(() => {
// // 使API
// // getFieldList().catch(err => {
// // Taro.showToast({
// // title: err?.description || '',
// // icon: 'none'
// // });
// // });
// });
// function onRefresh() {
// // 使API
// // return getFieldList().finally(() => {
// // Taro.stopPullDownRefresh();
// // });
// Taro.stopPullDownRefresh();
// }
//
// Taro.usePullDownRefresh(() => {
// onRefresh();
// });
function onNavTo({ airfieldId, droneSn, recordId }) {
Taro.navigateTo({
url: `/pages/supervisionMap/index?id=${id}`,
url: `/pages/supervisionMap/index?airfieldId=${airfieldId}&droneSn=${droneSn}&recordId=${recordId}`,
});
}
</script>
@ -134,21 +83,21 @@ function onNavTo(id) {
<template>
<div :class="s.root">
<div class="field-list">
<div class="field-card" v-for="item in fieldList" :key="item.recordId" @click="onNavTo(item.id)">
<div class="field-card" v-for="item in list" :key="item.recordId" @click="onNavTo(item)">
<div class="field-image">
<img :src="item.airfield.imageUrl" alt="场地图片" />
<div class="status-badge" :class="{ 'in-progress': item.inProgress, 'passed': item.isPass, 'failed': !item.isPass && !item.inProgress }">
{{ item.inProgress ? '进行中' : (item.isPass ? '通过' : '未通过') }}
<img :src="'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/tmp/2025/05/tmp_1746237402612144307.png' || item.airfield.imageUrl" alt="场地图片" />
<div class="status-badge" :class="{ 'in-progress': true }">
{{ '进行中' }}
</div>
</div>
<div class="field-info">
<div class="student-info">
<div class="student-name">{{ item.studentName }}</div>
<div class="student-name">{{ item.className }}: {{ item.studentName }}</div>
<div class="teacher-name">指导教师{{ item.teacherName }}</div>
</div>
<div class="field-name">
<span class="icon">📍</span>
{{ item.airfield.name }}
{{ item.airfieldName }}
</div>
<div class="info-item">
<span class="label">飞机编号</span>
@ -161,6 +110,9 @@ function onNavTo(id) {
</div>
</div>
</div>
<nut-empty v-if="!list.length" description="暂无数据" />
</div>
<TabBar />

93
src/pages/supervisionMap/BottomSide.vue

@ -0,0 +1,93 @@
<script setup>
import { computed, ref } from 'vue';
import { IconFont } from "@nutui/icons-vue-taro";
// import { toFixed } from '../../../utils/helpers';
import deviceCruise from '../../core/useDeviceCruise';
// import deviceIcon from '../../../assets/deviceIcon.png'
// const isVisible = ref(true);
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="uav4" :style="{ color: info?.droneOnLine ? 'snow' : 'red', 'font-size': '24px' }" />
<div class="value" v-if="info?.connectLoading">连接中...</div>
<div class="value" v-else :style="{ color: info?.droneOnLine ? 'snow' : 'red' }">{{ info?.droneOnLine ? '已连接飞机' : '飞机失联..' }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="battery" />
<div class="value">{{ info?.voltage || '-' }} v</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="measure" :style="{ transform: 'rotate(90deg)' }" />
<div class="value">{{ info?.height || '-' }} m</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="compass" :style="{ transform: 'rotate(45deg)' }"/>
<div class="value">{{ info?.yaw || '-' }}</div>
</div>
<div class="item">
<IconFont font-class-name="iconfont" class-prefix="icon" name="satellite" />
<div class="value">{{ info?.satellite || '-' }} {{ info?.fixTypeLabel || '-'}}</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 100px;
left: 100px;
//top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: snow;
font-size: 14px;
font-weight: bold;
//width: 100px;
//min-width: 100px;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-around;
gap: 10px;
padding: 10px;
//color: black;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.item {
display: flex;
align-items: center;
font-family: monospace;
//color: white;
.iconfont {
width: 10px;
height: 10px;
font-size: 16px;
//color: #4CAF50;
}
.value {
margin-left: 8px;
font-size: 12px;
}
}
}
}
</style>

235
src/pages/supervisionMap/LeftSide.vue

@ -0,0 +1,235 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
// import { toFixed } from '@/utils/helpers.js';
import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../../core/deviceCruise';
import { IconFont } from "@nutui/icons-vue-taro";
import Taro from "@tarojs/taro";
const isVisible = ref(true);
// const info = computed(() => ({}));
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
function onBack() {
Taro.navigateBack();
}
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="box">
<div class="countdown">
<div class="title">
<IconFont font-class-name="iconfont" class-prefix="icon" name="time" />
</div>
<div class="value">{{ toFixed(info.timeTicker || 0, 1) }}</div>
</div>
</div>
<div class="box box2">
<div class="data-item">
<div class="text" v-if="false">切线速度</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="tangent-speed" />
</div>
<div class="value-wrapper">{{ toFixed(info?.speed || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">航向偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="tangent-angle" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angle || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">高度偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="offset-v" />
</div>
<div class="value-wrapper">{{ toFixed(info?.vertical || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">水平偏差</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="offset-h" />
</div>
<div class="value-wrapper">{{ toFixed(info?.horizontal || 0, 2) }}</div>
</div>
<div class="data-item">
<div class="text" v-if="false">角速度</div>
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="angle-speed" />
</div>
<div class="value-wrapper">{{ toFixed(info?.angleSpeed || 0, 1) }}</div>
</div>
</div>
<div class="box box3">
<div class="data-item">
<div class="icon-wrapper">
<IconFont font-class-name="iconfont" class-prefix="icon" name="yibiaopan" />
</div>
<div class="value-wrapper">{{ toFixed(info?.yaw || 0, 1) }}</div>
</div>
</div>
<div class="box box4" @click="onBack">
<span>返回首页</span>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
// padding: 8px;
color: white;
font-size: 11px;
width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
:global {
.canvas-bg {
width: 100%;
height: 80%;
position: absolute;
//top: 0;
//right: 0;
//left: 0;
//bottom: 20px;
}
.nav {
position: absolute;
right: -20px;
top: 6px;
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 0 4px 4px 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
color: #7FFF00;
border-bottom: 1px solid rgb(255, 255, 255);
// padding: 4px 8px;
.countdown {
display: flex;
align-items: center;
padding: 0 8px;
//justify-content: center;
//justify-content: ;
.title {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
.value {
font-size: 24px;
font-weight: bold;
text-align: center;
padding: 5px;
}
}
.data-item {
display: flex;
align-items: center;
justify-content: center;
// gap: 12px;
// padding: 5px;
.text {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
}
.icon-wrapper {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
.value-wrapper {
flex: 1;
font-size: 18px;
font-weight: 500;
text-align: left;
padding-left: 8px;
}
}
}
.box2 {
padding: 8px;
}
.box3 {
flex: 1;
color: red;
padding: 4px 8px;
.icon-wrapper {
background: rgba(255, 0, 0, 0.1) !important;
}
}
.box4 {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
//height: 40px;
color: white;
//text-align: center;
border-bottom: none;
}
}
}
</style>

124
src/pages/supervisionMap/ResultModal.vue

@ -0,0 +1,124 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false
},
name: {
type: String,
default: '张三'
},
uavId: {
type: String,
default: 'UAV-001'
},
isPassed: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:show']);
const handleClose = () => {
emit('update:show', false);
};
</script>
<template>
<view v-show="show" :class="s.root">
<view class="modal-content">
<view class="close-btn" @tap="handleClose">×</view>
<view class="result-status" :class="{ 'pass': isPassed }">
{{ isPassed ? '通过' : '未通过' }}
</view>
<view class="info-item">
<text class="label">姓名</text>
<text class="value">{{ name }}</text>
</view>
<view class="info-item">
<text class="label">飞机编号</text>
<text class="value">{{ uavId }}</text>
</view>
</view>
</view>
</template>
<style lang="less" module="s">
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
:global{
.modal-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 12px;
min-width: 280px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
color: #666;
}
}
.result-status {
font-size: 20px;
font-weight: bold;
text-align: center;
margin-bottom: 16px;
color: #ff4d4f;
&.pass {
color: #52c41a;
}
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
.label {
color: #838383;
margin-right: 8px;
font-size: 14px;
}
.value {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
}
}
}
}
</style>

146
src/pages/supervisionMap/RightSide.vue

@ -0,0 +1,146 @@
<script setup>
import {computed, onUnmounted, ref} from 'vue';
// import { IconFont } from "@nutui/icons-vue-taro";
// import { toFixed } from '../../utils/helpers';
// import deviceCruise from '../../core/deviceCruise';
import droneImg from '../../assets/droneImg.png'
import {storeToRefs} from "pinia";
import {useAirFieldsStore, useFlightStore} from "../../stores";
import dayjs from "dayjs";
const { flightDetail } = storeToRefs(useFlightStore());
// const { licenseGradesList } = storeToRefs(useFlightStore());
defineProps({
info: {
type: Object,
default: () => ({}),
}
})
const weightClasses = {
small: "小型",
medium: "中型",
large: "大型",
};
const currentTime = ref(dayjs().format('HH:mm'));
let timer = setInterval(() => {
currentTime.value = dayjs().format('HH:mm');
}, 2000);
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
})
</script>
<template>
<div :class="s.root" :catch-move="true">
<!-- <canvas class="canvas-bg" type="2d" />-->
<div class="box box1">
<div class="data-item">{{ flightDetail?.studentName || '-' }}</div>
<div class="data-item">{{ flightDetail?.studentSn || '-' }}</div>
<div class="data-item">多旋翼- {{ weightClasses[flightDetail?.weightClass] || '?' }} 无人机</div>
<div class="data-item">
<image class="img" :src="droneImg" />
</div>
</div>
<div class="box box2">
{{ info?.text }}
</div>
<div class="box box3">
{{ currentTime }}
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 11px;
width: 100px;
//min-width: 100px;
z-index: 2;
display: flex;
flex-direction: column;
:global {
.canvas-bg {
width: 100%;
height: 100%;
position: absolute;
}
.box {
display: flex;
flex-direction: column;
gap: 2px;
font-family: monospace;
color: white;
border-bottom: 1px solid rgb(255, 255, 255);
padding: 4px 8px;
.data-item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
.icon-wrapper {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(127, 255, 0, 0.1);
border-radius: 4px;
}
.value-wrapper {
flex: 1;
font-size: 18px;
font-weight: 500;
text-align: left;
padding-left: 8px;
}
}
}
.box1 {
.img {
width: 20px;
height: 20px;
}
}
.box2 {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 16px;
}
.box3 {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
//height: 40px;
color: white;
//text-align: center;
border-bottom: none;
}
}
}
</style>

118
src/pages/supervisionMap/geo.js

@ -0,0 +1,118 @@
/**
* 地理空间函数库
*/
import * as math from 'mathjs';
import * as turf from '@turf/turf';
/**
* 将两个点连成一个向量并确保落在14象限
* @param point1
* @param point2
* @returns {Vector}
*/
export function pointToVector(point1, point2) {
const vec = math.subtract(point2, point1);
return vec[0] < 0 ? math.multiply(-1, vec) : vec;
}
/**
* 弧度2角度
* @param radians
* @returns {number}
*/
export function radToDeg(radians) {
return math.multiply(radians, math.divide(180, math.pi));
}
/**
* 计算二维向量与x轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithXAxis(vector) {
const [x] = vector;
const magnitude = math.norm(vector);
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = x / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算二维向量与 y 轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithYAxis(vector) {
const [, y] = vector;
const magnitude = math.norm(vector); // 计算向量的模长 ||v||
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = y / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = math.max(-1, math.min(1, cosTheta));
// 计算夹角 theta
return math.acos(safeCosTheta);
}
/**
* 计算两个二维向量之间的夹角弧度
* @param {number[]} vectorA - 第一个二维向量 [x1, y1]
* @param {number[]} vectorB - 第二个二维向量 [x2, y2]
* @returns {number} 夹角的弧度值
*/
export function angleBetweenVectors(vectorA, vectorB) {
const dotProduct = math.dot(vectorA, vectorB);
const magnitudeA = math.norm(vectorA);
const magnitudeB = math.norm(vectorB);
if (magnitudeA === 0 || magnitudeB === 0) {
throw new Error('其中一个向量是零向量,夹角未定义');
}
const cosTheta = dotProduct / (magnitudeA * magnitudeB);
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算两个坐标点的中点
* @param point1
* @param point2
* @returns {number[]}
*/
export function midPoint(point1, point2) {
const [x1, y1] = point1;
const [x2, y2] = point2;
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [+x.toFixed(8), +y.toFixed(8)];
}
/**
* 生成圆形路径
* @param center 圆心[lng, lat]
* @param radius 半径单位
* @param steps 分段数
* @returns {any}
*/
export function genCirclePath(center, radius, steps = 64) {
const geojson = turf.circle(center, radius / 1000, {
steps,
units: 'kilometers',
});
const [coords] = turf.getCoords(geojson);
return coords;
}

2
src/pages/supervisionMap/index.config.js

@ -1,5 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '监管详情',
navigationStyle: 'default',
navigationStyle: 'custom',
pageOrientation: 'landscape'
})

356
src/pages/supervisionMap/index.vue

@ -1,18 +1,34 @@
<script setup>
import { onMounted, ref, onUnmounted } from "vue";
import {onMounted, ref, onUnmounted, reactive, watch} from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import deviceCruise from '../../core/deviceCruise';
// import TrackPlayback from '../../components/TrackPlayback.vue';
import SupervisionData from '../../components/SupervisionData.vue';
import SupervisionSideData from '../../components/SupervisionSideData.vue';
import trackData from '../flightMap/track.json';
import { convertToTrajectory, creatEightShaped } from '../flightMap/utils';
//
const centerPoint = ref({
lat: 39.908692,
lng: 116.397477
import LeftSide from './LeftSide.vue';
import RightSide from './RightSide.vue';
import BottomSide from './BottomSide.vue';
import { creatEightShaped } from '../flightMap/utils';
import ResultModal from './ResultModal.vue';
import {useAirFieldsStore, useFlightStore, useStandardStore, useSupervisionStore} from "../../stores";
import { useDroneMarker } from "./useDroneMarker";
import { storeToRefs } from 'pinia';
import { GPS2GCJ } from '../../utils/helpers';
const { markers, rotate, polyline } = useDroneMarker();
const { getExamList, getEnvList } = useStandardStore()
const { wsDroneData } = useSupervisionStore();
const { position, attitude, battery, gps, tip, deviation, connectLoading, connectDrone, stageInfo, errorInfo, currentRecordId, droneOnLine } = storeToRefs(useSupervisionStore());
const { getLicenseGradesList, getAirFieldsDetail } = useAirFieldsStore();
const { getFlightDetail } = useFlightStore();
const { params } = Taro.useRouter();
console.log('params', params);
getLicenseGradesList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
// const droneSn = ref()
getRecordInfo(params?.recordId)
watch(currentRecordId, () => {
getRecordInfo(currentRecordId.value);
});
//
@ -20,14 +36,29 @@
const show = ref(true);
const firstMarker = ref(true);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = true;
initMap();
initEightShaped();
show.value = false;
// setMapInstance(mapContext);
init();
initAudio();
});
onUnmounted(() => {
if (audioManager) {
audioManager.destroy();
audioManager = null;
}
if (ws) {
confirmClose.value = true;
ws.close();
ws = null;
}
// eventBus.off('show-teaching-track-replay')
})
//
const polygons = ref([]);
//
@ -35,89 +66,123 @@
//
const markerPoints = ref([]);
//
const trajectory = convertToTrajectory(trackData, {
startTime: "2025-03-14T08:00:00Z",
interval: 1000 // 1
});
//
const bearing = ref(0);
//
const polyline = ref([{
points: trackData.map(coord => ({
latitude: coord[1],
longitude: coord[0]
})),
color: '#008000',
width: 4,
arrowLine: true
}]);
//
// const errorPoints = ref([{
// latitude: 39.908692,
// longitude: 116.397477,
// iconPath: '../../assets/red-marker.svg',
// width: 30,
// height: 30,
// callout: {
// content: '',
// color: '#FF0000',
// fontSize: 14,
// borderRadius: 4,
// bgColor: '#FFFFFF',
// padding: 8,
// display: 'ALWAYS'
// }
// //
// const polyline = ref([{
// points: trackData.map(coord => ({
// latitude: coord[1],
// longitude: coord[0]
// })),
// color: '#008000',
// width: 4,
// arrowLine: true
// }]);
//
function initMap() {
if (!mapContext) return;
//
let ws;
const confirmClose = ref(false);
function connect() {
if (!params?.droneSn) {
openToast('warn', '未获取到飞机编号,请重新扫码。');
return;
}
wsDroneData(params?.droneSn).then(wsTask => {
ws = wsTask;
wsTask.onMessage(() => {
// if (firstMarker.value && position.value?.lng) {
// initDevice({ ...position.value, ...attitude.value }, bearing.value);
// firstMarker.value = false;
// } else {
// moveDevice({ ...position.value, ...attitude.value }, bearing.value)
// }
});
//
// const bearing = ref(0);
//
function initEightShaped() {
if (!mapContext) return;
const center1 = [116.397477, 39.908692];
const center2 = [116.399477, 39.910692];
const mid = turf.midpoint(center1, center2).geometry.coordinates;
console.log('mid', mid);
wsTask.onError(() => {
// clearDevice()
// setTimeout(() => {
// sum += 1;
// connect();
// }, 3000);
ws.close()
});
centerPoint.value = {
lat: mid[1],
lng: mid[0]
};
//
let angle = turf.bearing(center1, center2);
wsTask.onClose(() => {
// initDevice();
if (confirmClose.value) return;
setTimeout(() => {
// sum += 1;
connect();
}, 3000);
})
});
}
//
angle = ((90 - angle) % 360 + 360) % 360;
function getRecordInfo(recordId = "") {
if (!recordId) return;
getFlightDetail(recordId).catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
}
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
const enableRotate = ref(false)
function init() {
//
Promise.all([
getExamList(),
getEnvList(),
getAirFieldsDetail(params?.airfieldId),
// getAirFieldsOfStudent(),
]).then(([examlist, envlist, airFieldsData]) => {
console.log('air', airFieldsData);
const airfield = airFieldsData?.data || {};
const { records = [] } = examlist || {}
const envItem = (envlist || []).find((item) => item.id === airfield.envGradeId) || {}
const standardData = records.find(
(item) =>
item.name === '8字圆圈半径' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const standardDiffData = records.find(
(item) =>
item.name === '8字水平偏差阈值' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
)
const point3standardData = records.find(
(item) =>
item.name === '点3中心筒范围-内' &&
item.licenseLevelId === airfield.licenseGradeId &&
item.envGrade === envItem.gradeName,
);
const center1 = GPS2GCJ([airfield.circle1Lng, airfield.circle1Lat]);
const center2 = GPS2GCJ([airfield.circle2Lng, airfield.circle2Lat]);
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped(
{
center: center1,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, {
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
{
center: center2,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, [6, 0, 1, 3, 4, 5, 2], { radius: 50 });
radius: standardData.value,
radiusDiff: standardDiffData.value,
centerWidth: 0.1,
},
[6, 0, 1, 3, 4, 5, 2],
{ radius: point3standardData.value },
);
//
polygons.value = shapePolygons;
markerPoints.value = shapeMarkers;
setTimeout(() => {
circles.value = shapeCircles;
markerPoints.value = shapeMarkers;
}, 500);
//
mapContext.includePoints({
@ -125,28 +190,99 @@
{ latitude: center1[1], longitude: center1[0] },
{ latitude: center2[1], longitude: center2[0] }
],
padding: [50, 50, 110, 50],
// padding: [130, 50, 130, 10],
success: (res) => {
setTimeout(() => {
//
const angle = turf.bearing(center1, center2);
// 使
bearing.value = angle;
// console.log('angle', bearing.value);
}, 5000);
// bearing.value = angle.toFixed(2) - 85;
console.log('地图视野设置成功', res);
rotate.value = 90 - angle;
bearing.value = 90 - angle;
connect();
}, 500);
// console.log('', res);
},
fail: (err) => {
enableRotate.value = true;
console.error('地图视野设置失败', err);
}
});
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
}
//
onUnmounted(() => {
if (deviceCruise) {
deviceCruise.destroy();
let audioManager;
const temTip = ref([]);
const tmp = ref({});
watch(tip, (val) => {
if (!val?.text) return;
temTip.value.push(val);
if (temTip.value.length === 1) {
tmp.value = val;
if (audioManager) {
audioManager.src = tmp.value.fileUrl;
audioManager.play();
}
}
});
function initAudio() {
audioManager = Taro.createInnerAudioContext();
// audioManager.onPlay(() => {
// console.log('')
// openToast();
// })
audioManager.onError((res) => {
console.error('音频错误:', res.errMsg, res.errCode);
});
audioManager.onEnded(() => {
temTip.value.splice(0, 1);
if (temTip.value.length === 0) return;
tmp.value = temTip.value[0];
audioManager.src = tmp.value.fileUrl;
audioManager.play();
});
// audioManager.value.title = '';
// console.log('tt', tt);
// audioManager.value.src = 'http://gcs-edu.obs.cn-east-2.myhuaweicloud.com/audio/2025/04/audio_1745814185096733360.mp3';
// audioManager.value.play();
// setTimeout(() => {
// audioManager.value.src = tt2;
// }, 5000)
// audioManager.value.onPlay(() => {
// console.log('');
// });
//
// audioManager.value.onError((res) => {
// console.log('', res);
// //
// });
//
// audioManager.value.onEnded(() => {
// console.log('');
// });
}
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
// title: '',
// bottom: '',
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
</script>
<template>
@ -154,36 +290,25 @@
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:markers="markerPoints"
:markers="[...markerPoints, ...markers]"
:polygons="polygons"
:polyline="[...polyline]"
:scale="20"
:circles="circles"
:polyline="polyline"
:enable-rotate="true"
:enable-rotate="enableRotate"
:rotate="bearing"
:enable-satellite="true"
:show-compass="true"
/>
<view v-if="show" class="exam-result-modal">
<view class="modal-content">
<view class="close-btn" @tap="show = false">×</view>
<view class="result-status" :class="{ 'pass': true }">
通过
</view>
<view class="info-item">
<text class="label">姓名</text>
<text class="value">张三</text>
</view>
<view class="info-item">
<text class="label">飞机编号</text>
<text class="value">UAV-001</text>
</view>
</view>
</view>
</view>
<SupervisionData class="real-time-data" />
<SupervisionSideData />
<LeftSide :info="{ ...deviation, ...stageInfo, ...errorInfo }" />
<RightSide :info="{ text: tmp?.text || '' }" />
<BottomSide :info="{ ...position, ...attitude, ...gps, ...battery, connectLoading, connectDrone, droneOnLine }"/>
</view>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
<ResultModal v-model:show="show" />
</template>
<style lang="less" module="s">
@ -206,6 +331,7 @@
#map {
width: 100%;
height: 100%;
transform: scale(2);
}
.exam-result-modal {

224
src/pages/supervisionMap/useConnector.js

@ -0,0 +1,224 @@
import { createGlobalState, useWebSocket } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import * as turf from '@turf/turf';
import gcoord from 'gcoord';
import * as urls from '../config/urls';
import * as geo from '../utils/geo';
import { GPS_FIX_TYPE, GPS_FIX_TYPE2 } from '../config/gpsFixTypeMap';
import { FLY_MODE } from '../config/flyModeMap';
import { FC_SYSTEM_STATUS } from '../config/fcSystemStatus';
import { ERRORS_COUNT_1, ERRORS_COUNT_2, ERRORS_COUNT_3 } from '../config/errorMap';
import { useAnnouncer } from './useAnnouncer';
const announcer = useAnnouncer();
export const useConnector = createGlobalState(() => {
const { ws, status, data, send, open, close } = useWebSocket(urls.WS_URL, {
immediate: false,
heartbeat: {
interval: 3000,
},
});
watch(data, (val) => {
if (val === 'ping') {
send('pong');
}
});
const isConnecting = computed(() => status.value === 'CONNECTING');
const isConnected = computed(() => status.value === 'OPEN');
const time = ref({});
const battery = ref({});
const gps = ref({});
const position = ref({});
const attitude = ref({});
const home = ref({});
const homeAngle = ref(0);
const modeName = ref('N/A');
const sysStatus = ref('N/A');
const extra = ref({});
const info = computed(() => {
let result = data.value || '';
if (!`${data.value}`.startsWith('{')) {
return {};
}
try {
result = JSON.parse(result);
} catch (e) {
return {};
}
return result;
});
// 闲置计时器(闲置时做些动作)
let idleTimer = null;
watch(info, (val) => {
// info有变化时,清掉上一个计时器
if (idleTimer) clearTimeout(idleTimer);
// 电池信息、故障信息
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#22-%E7%B3%BB%E7%BB%9F%E7%8A%B6%E6%80%81-sys_status
if (val?.msgId === 1) {
const { VoltageBattery, CurrentBattery, BatteryRemaining, ErrorsCount1, ErrorsCount2, ErrorsCount3 } = val.data || {};
const voltage = +(VoltageBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cV即厘伏(mavlink文档中写的是mV即毫伏)
const current = +(CurrentBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cA即厘安
const remaining = BatteryRemaining >= 0 ? BatteryRemaining : null; // 单位:%
battery.value = { voltage, current, remaining };
// todo 临时
if (ErrorsCount1) {
const { [ErrorsCount1]: errorContent } = ERRORS_COUNT_1;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount2) {
const { [ErrorsCount2]: errorContent } = ERRORS_COUNT_2;
announcer.alarmThrottle(errorContent);
}
if (ErrorsCount3) {
const { [ErrorsCount3]: errorContent } = ERRORS_COUNT_3;
announcer.alarmThrottle(errorContent);
}
}
// 系统时间
// https://mavlink.io/en/messages/common.html#SYSTEM_TIME
if (val?.msgId === 2) {
const { TimeUnixUsec, TimeBootMs } = val.data || {};
const timestamp = parseInt(TimeUnixUsec / 1e3, 10); // to毫秒
const boot = TimeBootMs; // 毫秒
time.value = { timestamp, boot };
}
// GPS(with RTK)
// https://mavlink.io/en/messages/common.html#GPS2_RAW
if (val?.msgId === 124) {
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const fixTypeLabel = GPS_FIX_TYPE2.get(fixType);
gps.value = { fixType, fixTypeLabel, satellite };
}
// GPS(普通GPS)
// https://mavlink.io/en/messages/common.html#GPS_INPUT
if (val?.msgId === 232) {
if ('fixType' in gps.value) return;
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const { [fixType]: fixTypeLabel } = GPS_FIX_TYPE;
gps.value = { fixType, fixTypeLabel, satellite };
}
// 飞机位置
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#25-%E8%9E%8D%E5%90%88%E7%BB%8F%E7%BA%AC%E5%BA%A6%E9%80%9F%E5%BA%A6-global_position_int
if (val?.msgId === 33) {
const { Lon, Lat, Alt, RelativeAlt, Vx, Vy, Vz } = val.data || {};
const [lng, lat] = gcoord.transform([Lon / 1e7, Lat / 1e7], gcoord.WGS84, gcoord.GCJ02);
const alt = Alt / 1e3; // 源值毫米
const height = RelativeAlt / 1e3; // 源值毫米
const sx = (+Vx || 0) / 1e2; // 向北速度分量(源值厘米)
const sy = (+Vy || 0) / 1e2; // 向东速度分量(源值厘米)
const sz = (+Vz || 0) / 1e2; // 向下速度分量(源值厘米)
const hSpeed = Math.hypot(sx, sy);
const vSpeed = Math.abs(sz);
const { lng: hLng, lat: hLat } = home.value || {};
const homeDist = hLng !== undefined ? turf.distance([lng, lat], [hLng, hLat]) * 1e3 : null; // distance单位是km
position.value = { lng, lat, alt, height, hSpeed, vSpeed, sx, sy, sz, homeDist };
}
// 飞机姿态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#26-%E9%A3%9E%E6%9C%BA%E5%A7%BF%E6%80%81%E8%A7%92%E9%80%9F%E5%BA%A6-attitude
if (val?.msgId === 30) {
const { Yaw, Pitch, Roll } = val.data || {};
const yaw = +geo.radToDeg(Yaw || 0).toFixed(1);
const pitch = +geo.radToDeg(Pitch || 0).toFixed(1);
const roll = +geo.radToDeg(Roll || 0).toFixed(1);
attitude.value = { yaw, pitch, roll };
}
// 油门
// https://mavlink.io/en/messages/common.html#VFR_HUD
if (val?.msgId === 74) {
const { Throttle } = val.data || {};
extra.value = { throttle: Throttle };
}
// home点
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#23-home%E7%82%B9-home_position
if (val?.msgId === 242) {
const { Longitude, Latitude } = val.data || {};
let lng = +(Longitude / 1e7).toFixed(7);
let lat = +(Latitude / 1e7).toFixed(7);
[lng, lat] = [lat, lng]; // todo 临时颠倒一下
home.value = { lng, lat };
}
// 飞行模式、解锁状态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#110-%E8%87%AA%E5%AE%9A%E4%B9%89%E9%A3%9E%E8%A1%8C%E6%A8%A1%E5%BC%8F-vkfly_custom_mode
// https://mavlink.io/en/messages/common.html#HEARTBEAT
if (val?.msgId === 0) {
const { CustomMode, SystemStatus } = val.data || {};
const { [CustomMode]: label } = FLY_MODE;
modeName.value = label || 'N/A';
sysStatus.value = FC_SYSTEM_STATUS.get(SystemStatus) || 'N/A';
}
// 1秒后,若info没有新的变化,则清空这些数据
idleTimer = setTimeout(() => {
time.value = {};
battery.value = {};
gps.value = {};
position.value = {};
attitude.value = {};
home.value = {};
modeName.value = 'N/A';
sysStatus.value = 'N/A';
}, 1000);
});
// 动态计算机头与home点的夹角
watch([position, attitude], () => {
const { lng: hLng, lat: hLat } = home.value || {};
if (hLng === undefined) {
homeAngle.value = 0;
return;
}
const { lng: dLng, lat: dLat } = position.value || {};
const { yaw } = attitude.value || {};
if (dLng === undefined || yaw === undefined) return;
const [lng, lat] = [hLng - dLng, hLat - dLat]; // 以飞机为起点,home为终点的向量
// 不能是0向量(飞机与home点完全重合时,则令机头始终指向home)
if (!lng && !lat) {
homeAngle.value = 0;
return;
}
const rad = geo.angleWithYAxis([lng, lat]); // 与(0,1)这个单位向量的夹角
const deg = geo.radToDeg(rad) * (lng >= 0 ? 1 : -1); // 通过x轴正负来决定角度正负
homeAngle.value = +(deg - yaw).toFixed(2) || 0;
});
return {
ws,
isConnecting,
isConnected,
time,
battery,
gps,
position,
attitude,
home,
homeAngle,
modeName,
sysStatus,
extra,
send,
connect: open,
reconnect: open,
close,
};
});
export default null;

142
src/pages/supervisionMap/useDroneMarker.js

@ -0,0 +1,142 @@
import {computed, onMounted, ref, watch} from 'vue';
import deviceIcon from "../../assets/droneImg.png";
import droneDisImg from '../../assets/droneDisImg.png';
import Taro from "@tarojs/taro";
import {storeToRefs} from "pinia";
import {useSupervisionStore} from "../../stores";
export function useDroneMarker() {
let mapContext;
const { position, attitude, connectDrone, droneOnLine } = storeToRefs(useSupervisionStore());
const rotate = ref(0);
const path = ref([]);
watch(position, (nv) => {
if (nv?.lng) {
path.value.push({
latitude: nv?.lat,
longitude: nv?.lng,
});
}
})
const polyline = computed(() => {
if (path.value.length < 2) {
return [];
}
return [{
points: path.value || [],
color: '#008000',
width: 0.8,
}]
});
const markers = computed(() => {
if (!Object.keys(position.value).length || !mapContext) return [];
return [{
id: 1e7,
iconPath: droneOnLine.value ? deviceIcon : droneDisImg,
width: 18,
height: 18,
anchor: { x: 0.5, y: 0.5 },
latitude: position.value?.lat,
longitude: position.value?.lng,
rotate: attitude.value?.yaw + rotate.value,
}]
})
onMounted(() => {
mapContext = Taro.createMapContext('map');
})
let realTimePoint = [];
let lastPolylineUpdateTime = 0; // 添加最后更新时间记录
// const polyline = ref([]);
function initDevice(point, mapRotate = 0) {
// if (!point) {
// markers.value = undefined;
// return;
// }
if (!point.lng || !point.lat) {
markers.value = undefined;
return;
}
const { lng, lat, yaw } = point;
markers.value = {
...markerConfig,
latitude: lat,
longitude: lng,
rotate: yaw + mapRotate,
};
realTimePoint.push(point);
// 控制轨迹线更新频率为每秒一次
const now = Date.now();
if (now - lastPolylineUpdateTime >= 1000) { // 1000ms = 1秒
if (realTimePoint.length >= 3) {
polyline.value = [{
points: realTimePoint.map(item => ({
latitude: item.lat,
longitude: item.lng,
})),
color: '#008000',
width: 0.8,
}];
}
lastPolylineUpdateTime = now;
}
}
function clearDevice() {
markers.value = undefined;
polyline.value = undefined;
realTimePoint = [];
lastPolylineUpdateTime = 0; // 重置最后更新时间
}
// function moveDevice(point, mapRotate = 0) {
// const { lng, lat, yaw } = point;
//
// map.translateMarker({
// markerId,
// destination: {
// longitude: lng,
// latitude: lat,
// },
// autoRotate: false,
// duration: 1,
// rotate: yaw + mapRotate,
// moveWithRotate: true,
// animationEnd: () => {
// // this._that.timelyData = { ...nextData };
// // this._that.currentIndex += 1;
// // if (this._that.isPlaying) {
// // this._that.elapsedMs += (duration * this._that.speedRate);
// // if (this._that.elapsedMs >= this._that.totalTime) {
// // this._that.handleStop();
// // return;
// // }
// // this._that._renderDevice();
// // }
// }
// });
// }
// const polyine = ref();
// function renderTrack(points = []) {
//
// }
return {
// initDevice,
// moveDevice,
markers,
clearDevice,
polyline,
rotate,
}
}

4
src/stores/index.js

@ -11,4 +11,6 @@ export * from './modules/flightStore';
export * from './modules/routePlanStore';
export * from './modules/returnTripStore';
export * from './modules/evaluationStore';
export * from './modules/standardStore';
export * from './modules/supervisionStore';
export * from './modules/airfieldsStore';

143
src/stores/modules/airfieldsStore.js

@ -0,0 +1,143 @@
/**
* airfieldsStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as urls from '../../config/urls';
import {UPDATE_AIRFIELD} from "../../config/urls";
export const useAirFieldsStore = defineStore('airfields', () => {
// state
const licenseGradesList = ref([]);
const droneList = ref([]);
const classList = ref([]);
const airfieldList = ref([]);
const INIT_DATA = {
name: '',
circle1Lat: 0,
circle1Lng: 0,
circle2Lat: 0,
circle2Lng: 0,
envGradeId: 0,
licenseGradeId: 0,
// imageUrl: "",
// createDate: "",
// teacherId: 0,
// organizationId: 0,
}
const formData = ref({ ...INIT_DATA });
// actions
function getAirfieldList() {
return http.get(urls.GET_AIRFIELDS_LIST, {
params: {
pageNum: 1,
pageSize: 9999,
}
}).then(({ data: { data } = {} }) => {
const { records: list = [] } = data;
airfieldList.value = list || [];
return data;
});
}
function airfieldBindClass(params = {}) {
return http.post(urls.AIRFIELD_BIND_CLASS, params).then(({ data }) => {
getAirfieldList();
return data;
});
}
function getAirFieldsDetail(id) {
return http.get(urls.GET_AIRFIELDS_DETAIL(id)).then(({ data }) => {
return data;
});
}
function getAirFieldsOfStudent() {
return http.get(urls.GET_AIRFIELDS_OF_STUDENT).then(({ data }) => {
return data;
});
}
function deleteAirField(id) {
return http.delete(urls.DELETE_AIRFIELD(id)).then((data) => {
getAirfieldList();
return data;
})
}
function getDroneList() {
return http.get(urls.GET_DRONE_LIST, {
params: {
pageNum: 1,
pageSize: 9999,
}
}).then(({ data: { data } = {} }) => {
const { records: list = [] } = data;
droneList.value = list || [];
return data;
});
}
function getClassList() {
return http.get(urls.GET_CLASS_LIST, {
params: {
pageNum: 1,
pageSize: 9999,
}
}).then(({ data: { data } = {} }) => {
const { records: list = [] } = data;
classList.value = list || [];
return data;
});
}
function getLicenseGradesList() {
return http.get(urls.GET_LICENSE_GRADES_LIST).then(({ data: { data } = {} }) => {
licenseGradesList.value = data || [];
return data;
});
}
function createAirfield(params = {}) {
return http.post(urls.CREATE_AIRFIELD, params).then(({ data }) => {
getAirfieldList();
return data;
});
}
function updateAirfield(params = {}) {
return http.put(urls.UPDATE_AIRFIELD(params.id), params).then(({ data }) => {
getAirfieldList();
return data;
});
}
function resetFormData() {
formData.value = { ...INIT_DATA };
}
return {
getAirfieldList,
getAirFieldsDetail,
getAirFieldsOfStudent,
airfieldBindClass,
deleteAirField,
getDroneList,
droneList,
airfieldList,
getClassList,
classList,
getLicenseGradesList,
licenseGradesList,
createAirfield,
formData,
resetFormData,
updateAirfield,
};
});

35
src/stores/modules/authStore.js

@ -30,6 +30,7 @@ export const useAuthStore = defineStore('auth', () => {
const requestDate = helpers.pick(formData, [
'password',
'phone',
'code',
]);
return http.post(urls.LOGIN_WITH_PASSWORD, requestDate, {
@ -56,7 +57,40 @@ export const useAuthStore = defineStore('auth', () => {
});
}
function studentLogin(formData = {}) {
const requestDate = helpers.pick(formData, [
'password',
'sn',
'code',
]);
return http.post(urls.STUDENT_LOGIN_WITH_PASSWORD, requestDate, {
withToken: false
}).then(({ data: { data } = {} }) => {
// const { data = {} } = data;
// 获取信息
console.log('student', data);
const { token: accessToken = '', student = {} } = data || {};
if (accessToken) {
auth.saveToken(accessToken);
// 更新信息
const newUserInfo = {
...(student || {}),
};
userInfo.value = { ...newUserInfo };
// // 保存信息
UserInfo.save(newUserInfo);
}
return data;
});
}
function wechatAuth({ code, sceneId } = {}) {
auth.removeToken();
userInfo.value = {};
UserInfo.remove();
return http.post(urls.WECHAT_AUTH_URL, {
code,
sceneId
@ -122,6 +156,7 @@ export const useAuthStore = defineStore('auth', () => {
return {
userInfo,
loginWithPassword,
studentLogin,
isSuperAdmin,
isOrgAdmin,
isTeacherAdmin,

26
src/stores/modules/flightStore.js

@ -12,6 +12,8 @@ export const useFlightStore = defineStore('flight', () => {
const flightExtra = ref({ total: null });
const flightQueries = ref({ pageNum: 1, pageSize: 10 });
const flightDetail = ref({});
// actions
function getFlightList(params = {}) {
const queryParams = { ...flightQueries.value, ...params };
@ -26,6 +28,7 @@ export const useFlightStore = defineStore('flight', () => {
function getFlightDetail(id) {
return http.get(urls.GET_FLIGHT_DETAIL(id)).then(({ data }) => {
flightDetail.value = { ...(data?.data || {}) };
return data;
});
}
@ -36,14 +39,35 @@ export const useFlightStore = defineStore('flight', () => {
});
}
function studentBindDrone(params = {}) {
return http.get(urls.STUDENT_BIND_DRONE, { params }).then(({ data: { data } = {} }) => {
return data;
});
}
// actions
function getRecordList() {
return http.get(urls.GET_FLIGHT_LIST, {
params: {
inProgress: true,
pageNum: 1,
pageSize: 9999,
}
}).then(({ data: { data } = {} }) => {
const { records: list = [] } = data;
return list;
});
}
return {
flightList,
flightExtra,
flightQueries,
flightDetail,
getFlightList,
getFlightDetail,
getFlightTracks
getFlightTracks,
studentBindDrone,
getRecordList,
};
});

99
src/stores/modules/standardStore.js

@ -0,0 +1,99 @@
/**
* standardStore
*/
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import * as urls from '../../config/urls';
import http from '../../utils/http';
// import * as helpers from '../../utils/helpers';
export const useStandardStore = defineStore('standard', () => {
// state
const envList = ref([]);
const examList = ref([]);
const examExtra = ref({ total: null });
const examQueries = ref({ pageNum: 1, pageSize: 9999});
const envTidyList = computed(() => {
const temp = { id: 1 };
envList.value.forEach((item, index) => {
temp[item.gradeName] = {
index,
id: item.id,
gradeName: item.gradeName,
value: `${item.windMin}-${item.windMax}`,
originData: { ...item },
};
});
return [temp];
});
const examTidyList = computed(() => {
const groupedByType = {};
// 按type和name分组
examList.value.forEach((item) => {
if (!groupedByType[item.type]) {
groupedByType[item.type] = {};
}
const key = `${item.name}_${item.unit}`;
if (!groupedByType[item.type][key]) {
groupedByType[item.type][key] = {
name: item.name,
unit: item.unit,
remark: item.remark,
type: item.type,
sort: item.sort,
};
}
// 映射licenseName和envGrade到表格列号
let colKey;
if (item.licenseName === '驾驶员') {
colKey = item.envGrade === '一级' ? 'vlos_1' :
item.envGrade === '二级' ? 'vlos_2' : 'vlos_3';
} else if (item.licenseName === '机长') {
colKey = item.envGrade === '一级' ? 'bvlos_1' :
item.envGrade === '二级' ? 'bvlos_2' : 'bvlos_3';
} else if (item.licenseName === '教员') {
colKey = item.envGrade === '一级' ? 'instructor_1' :
item.envGrade === '二级' ? 'instructor_2' : 'instructor_3';
}
groupedByType[item.type][key][colKey] = item.value;
groupedByType[item.type][key][`${colKey}_data`] = { ...item };
});
return groupedByType;
});
// actions
function getEnvList(queries = {}) {
return http.get(urls.GET_ENV_LIST, { params: queries }).then(({ data: { data } = {} }) => {
envList.value = data || [];
return data;
});
}
function getExamList() {
const queryParams = { ...examQueries.value };
return http.get(urls.GET_EXAM_LIST, { params: queryParams }).then(({ data: { data } = {} }) => {
const { records = [], total = 0 } = data;
examList.value = records || [];
examExtra.value = { ...examExtra.value, total };
examQueries.value = { ...examQueries.value, ...queryParams };
return data;
});
}
return {
envList,
envTidyList,
examList,
examExtra,
examQueries,
examTidyList,
getEnvList,
getExamList,
};
});

320
src/stores/modules/supervisionStore.js

@ -2,16 +2,237 @@
* supervisionStore
* 场地监管相关接口
*/
import { ref } from 'vue';
import {computed, ref, watch} from 'vue';
import { defineStore } from 'pinia';
import * as urls from '../../config/urls';
import http from '../../utils/http';
import Taro from "@tarojs/taro";
import gcoord from "gcoord";
import * as turf from "@turf/turf";
import { GPS_FIX_TYPE, GPS_FIX_TYPE2 } from '../../config/gpsFixTypeMap';
import { FLY_MODE } from '../../config/flyModeMap';
import { FC_SYSTEM_STATUS } from '../../config/fcSystemStatus';
import * as geo from '../../utils/geo';
import { TIP_TEXT } from "../../config/tipTextMap";
export const useSupervisionStore = defineStore('supervision', () => {
const fieldList = ref([]);
const flightQueries = ref({ pageNum: 1, pageSize: 99999, inProgress: true });
const connectDrone = ref(false);
// const disconnectDrone = ref(true);
const connectLoading = ref(false);
const time = ref({});
const battery = ref({});
const gps = ref({});
const position = ref({});
const attitude = ref({});
const home = ref({});
const homeAngle = ref(0);
const modeName = ref('N/A');
const sysStatus = ref('N/A');
const extra = ref({});
const tip = ref({});
const deviation = ref({});
const stageInfo = ref({});
const errorInfo = ref({});
const currentRecordId = ref();
const droneOnLine = computed(() => connectDrone.value && !connectLoading.value && Object.keys(position.value).length);
const data = ref();
const info = computed(() => {
let result = data.value || '';
if (!`${data.value}`.startsWith('{')) {
return {};
}
try {
result = JSON.parse(result);
} catch (e) {
return {};
}
return result;
});
// 闲置计时器(闲置时做些动作)
let idleTimer = null;
watch(info, (val) => {
// info有变化时,清掉上一个计时器
if (idleTimer) clearTimeout(idleTimer);
if (val?.msgType === 9) {
const { recordId } = val?.data || {};
currentRecordId.value = recordId;
}
if (val?.msgType === 5) {
const { timestamp, lat: Lat, lng: Lng, height, audioCode } = val?.data || {};
const [lng, lat] = gcoord.transform([Lng, Lat], gcoord.WGS84, gcoord.GCJ02);
errorInfo.value = {
timestamp,
lng,
lat,
height,
audioCode,
}
}
if (val?.msgType === 4) {
const { stage, timeTicker } = val?.data || {};
stageInfo.value = {
stage,
timeTicker: Math.floor(timeTicker / 1000),
}
}
if (val?.msgType === 3) {
const { speed, angle, horizontal, vertical, angleSpeed } = val?.data || {};
deviation.value = { speed, angle, horizontal, vertical, angleSpeed };
}
if (val?.msgType === 2) {
// const { } = val.data || {};
// console.log(val.data); //first_start_hints
// tip.value = {};
if (TIP_TEXT[val?.data]) {
tip.value = { ...TIP_TEXT[val?.data] };
}
// console.log('tip', tip.value.text, tip.value.fileUrl);
}
// 电池信息、故障信息
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#22-%E7%B3%BB%E7%BB%9F%E7%8A%B6%E6%80%81-sys_status
if (val?.msgId === 1) {
const { VoltageBattery, CurrentBattery, BatteryRemaining } = val.data || {};
const voltage = +(VoltageBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cV即厘伏(mavlink文档中写的是mV即毫伏)
const current = +(CurrentBattery / 1e2 || 0).toFixed(1) || null; // 飞控给的电压是cA即厘安
const remaining = BatteryRemaining >= 0 ? BatteryRemaining : null; // 单位:%
battery.value = { voltage, current, remaining };
}
// 系统时间
// https://mavlink.io/en/messages/common.html#SYSTEM_TIME
if (val?.msgId === 2) {
const { TimeUnixUsec, TimeBootMs } = val.data || {};
const timestamp = parseInt(TimeUnixUsec / 1e3, 10); // to毫秒
const boot = TimeBootMs; // 毫秒
time.value = { timestamp, boot };
}
// GPS(with RTK)
// https://mavlink.io/en/messages/common.html#GPS2_RAW
if (val?.msgId === 124) {
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const fixTypeLabel = GPS_FIX_TYPE2.get(fixType);
gps.value = { fixType, fixTypeLabel, satellite };
}
// GPS(普通GPS)
// https://mavlink.io/en/messages/common.html#GPS_INPUT
if (val?.msgId === 232) {
if ('fixType' in gps.value) return;
const { FixType: fixType, SatellitesVisible: satellite } = val.data || {};
const { [fixType]: fixTypeLabel } = GPS_FIX_TYPE;
gps.value = { fixType, fixTypeLabel, satellite };
}
// 飞机位置
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#25-%E8%9E%8D%E5%90%88%E7%BB%8F%E7%BA%AC%E5%BA%A6%E9%80%9F%E5%BA%A6-global_position_int
if (val?.msgId === 33) {
const { Lon, Lat, Alt, RelativeAlt, Vx, Vy, Vz } = val.data || {};
const [lng, lat] = gcoord.transform([Lon / 1e7, Lat / 1e7], gcoord.WGS84, gcoord.GCJ02);
const alt = Alt / 1e3; // 源值毫米
const height = RelativeAlt / 1e3; // 源值毫米
const sy = (+Vx || 0) / 1e2; // 向北速度分量(源值厘米)
const sx = (+Vy || 0) / 1e2; // 向东速度分量(源值厘米)
const sz = (+Vz || 0) / 1e2; // 向下速度分量(源值厘米)
const hSpeed = Math.hypot(sx, sy);
const vSpeed = Math.abs(sz);
const { lng: hLng, lat: hLat } = home.value || {};
const homeDist = hLng !== undefined ? turf.distance([lng, lat], [hLng, hLat]) * 1e3 : null; // distance单位是km
position.value = { lng, lat, alt, height, hSpeed, vSpeed, sx, sy, sz, homeDist };
}
// 飞机姿态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#26-%E9%A3%9E%E6%9C%BA%E5%A7%BF%E6%80%81%E8%A7%92%E9%80%9F%E5%BA%A6-attitude
if (val?.msgId === 30) {
const { Yaw, Pitch, Roll, Yawspeed } = val.data || {};
const yaw = +geo.radToDeg(Yaw || 0).toFixed(1);
const pitch = +geo.radToDeg(Pitch || 0).toFixed(1);
const roll = +geo.radToDeg(Roll || 0).toFixed(1);
const yawSpeed = +geo.radToDeg(Yawspeed || 0).toFixed(1);
attitude.value = { yaw, pitch, roll, yawSpeed };
}
// 油门
// https://mavlink.io/en/messages/common.html#VFR_HUD
if (val?.msgId === 74) {
const { Throttle } = val.data || {};
extra.value = { throttle: Throttle };
}
// home点
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#23-home%E7%82%B9-home_position
if (val?.msgId === 242) {
const { Longitude, Latitude } = val.data || {};
let lng = +(Longitude / 1e7).toFixed(7);
let lat = +(Latitude / 1e7).toFixed(7);
[lng, lat] = [lat, lng]; // todo 临时颠倒一下
home.value = { lng, lat };
}
// 飞行模式、解锁状态
// http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink/src/main#110-%E8%87%AA%E5%AE%9A%E4%B9%89%E9%A3%9E%E8%A1%8C%E6%A8%A1%E5%BC%8F-vkfly_custom_mode
// https://mavlink.io/en/messages/common.html#HEARTBEAT
if (val?.msgId === 0) {
const { CustomMode, SystemStatus } = val.data || {};
const { [CustomMode]: label } = FLY_MODE;
modeName.value = label || 'N/A';
sysStatus.value = FC_SYSTEM_STATUS.get(SystemStatus) || 'N/A';
}
// 1秒后,若info没有新的变化,则清空这些数据
idleTimer = setTimeout(() => {
time.value = {};
battery.value = {};
gps.value = {};
position.value = {};
attitude.value = {};
home.value = {};
modeName.value = 'N/A';
sysStatus.value = 'N/A';
tip.value = {};
deviation.value = {};
stageInfo.value = {};
errorInfo.value = {};
}, 1000);
});
// 动态计算机头与home点的夹角
watch([position, attitude], () => {
const { lng: hLng, lat: hLat } = home.value || {};
if (hLng === undefined) {
homeAngle.value = 0;
return;
}
const { lng: dLng, lat: dLat } = position.value || {};
const { yaw } = attitude.value || {};
if (dLng === undefined || yaw === undefined) return;
const [lng, lat] = [hLng - dLng, hLat - dLat]; // 以飞机为起点,home为终点的向量
// 不能是0向量(飞机与home点完全重合时,则令机头始终指向home)
if (!lng && !lat) {
homeAngle.value = 0;
return;
}
const rad = geo.angleWithYAxis([lng, lat]); // 与(0,1)这个单位向量的夹角
const deg = geo.radToDeg(rad) * (lng >= 0 ? 1 : -1); // 通过x轴正负来决定角度正负
homeAngle.value = +(deg - yaw).toFixed(2) || 0;
});
// actions
function getFieldList() {
return http.get(urls.GET_FLIGHT_LIST, { params: flightQueries }).then(({ data: { data } = {} }) => {
@ -44,8 +265,103 @@ export const useSupervisionStore = defineStore('supervision', () => {
// });
// }
function wsDroneData(droneId = '') {
if (!droneId) return;
let heartbeatTimer;
let ws;
connectLoading.value = true;
const heartbeat = () => {
if (ws && ws.readyState === 1) { // 1 = OPEN
ws.send({
data: 'ping',
fail: () => {}
});
}
};
const startHeartbeat = () => {
heartbeatTimer = setInterval(heartbeat, 3000); // 每30秒发送一次心跳
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
return Taro.connectSocket({
url: urls.GET_DRONE_REAL_TIME_DATA + `?droneId=${droneId}`,
// success: function () {
// // console.log('connect success');
// },
// fail: () => {
// setTimeout(() => {
// wsDroneData(droneId);
// }, 3000);
// }
}).then(task => {
ws = task;
task.onOpen(function () {
connectLoading.value = false;
connectDrone.value = true;
// console.log('onOpen');
// task.send({ data: 'xxx' });
startHeartbeat(); // 连接打开后开始心跳
});
task.onMessage(function (msg) {
// console.log('onMessage: ', msg);
data.value = msg?.data || '';
// 不要在收到消息后就关闭连接
// task.close()
// setTimeout(() => {
// task.close();
// }, 5000);
});
task.onError(function () {
console.log('onError');
connectDrone.value = false;
connectLoading.value = false;
stopHeartbeat(); // 发生错误时停止心跳
// wsDroneData(droneId);
// task.close();
});
task.onClose(function (e) {
console.log('onClose: ', e);
connectDrone.value = false;
connectLoading.value = false;
stopHeartbeat(); // 连接关闭时停止心跳
// 可以在这里添加重连逻辑
// setTimeout(() => {
// wsDroneData(droneId);
// }, 5000); // 5秒后尝试重连
});
return task;
});
}
return {
fieldList,
getFieldList,
wsDroneData,
position,
attitude,
tip,
deviation,
connectDrone,
connectLoading,
gps,
errorInfo,
stageInfo,
battery,
currentRecordId,
droneOnLine,
};
});

118
src/utils/geo.js

@ -0,0 +1,118 @@
/**
* 地理空间函数库
*/
import * as math from 'mathjs';
import * as turf from '@turf/turf';
/**
* 将两个点连成一个向量并确保落在14象限
* @param point1
* @param point2
* @returns {Vector}
*/
export function pointToVector(point1, point2) {
const vec = math.subtract(point2, point1);
return vec[0] < 0 ? math.multiply(-1, vec) : vec;
}
/**
* 弧度2角度
* @param radians
* @returns {number}
*/
export function radToDeg(radians) {
return math.multiply(radians, math.divide(180, math.pi));
}
/**
* 计算二维向量与x轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithXAxis(vector) {
const [x] = vector;
const magnitude = math.norm(vector);
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = x / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算二维向量与 y 轴的夹角弧度
* @param {number[]} vector - 二维向量 [x, y]
* @returns {number} 夹角的弧度值
*/
export function angleWithYAxis(vector) {
const [, y] = vector;
const magnitude = math.norm(vector); // 计算向量的模长 ||v||
if (magnitude === 0) {
throw new Error('零向量的夹角未定义');
}
const cosTheta = y / magnitude;
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = math.max(-1, math.min(1, cosTheta));
// 计算夹角 theta
return math.acos(safeCosTheta);
}
/**
* 计算两个二维向量之间的夹角弧度
* @param {number[]} vectorA - 第一个二维向量 [x1, y1]
* @param {number[]} vectorB - 第二个二维向量 [x2, y2]
* @returns {number} 夹角的弧度值
*/
export function angleBetweenVectors(vectorA, vectorB) {
const dotProduct = math.dot(vectorA, vectorB);
const magnitudeA = math.norm(vectorA);
const magnitudeB = math.norm(vectorB);
if (magnitudeA === 0 || magnitudeB === 0) {
throw new Error('其中一个向量是零向量,夹角未定义');
}
const cosTheta = dotProduct / (magnitudeA * magnitudeB);
// 处理浮点数精度问题,确保 cosTheta 在 [-1, 1] 范围内
const safeCosTheta = Math.max(-1, Math.min(1, cosTheta));
return math.acos(safeCosTheta);
}
/**
* 计算两个坐标点的中点
* @param point1
* @param point2
* @returns {number[]}
*/
export function midPoint(point1, point2) {
const [x1, y1] = point1;
const [x2, y2] = point2;
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [+x.toFixed(8), +y.toFixed(8)];
}
/**
* 生成圆形路径
* @param center 圆心[lng, lat]
* @param radius 半径单位
* @param steps 分段数
* @returns {any}
*/
export function genCirclePath(center, radius, steps = 64) {
const geojson = turf.circle(center, radius / 1000, {
steps,
units: 'kilometers',
});
const [coords] = turf.getCoords(geojson);
return coords;
}

25
src/utils/helpers.js

@ -2,6 +2,7 @@
* 辅助函数
*/
import dayjs from 'dayjs';
import gcoord from "gcoord";
/**
* 等待异步结果
@ -117,7 +118,7 @@ export function numeric(input, substitution = '-') {
export const toFixed = (num, digits = 2) => (Number(num || 0).toFixed(digits)) - 0;
export const falsyTo = (fv, to) => (fv || fv === 0 ? fv : to);
export const falsyTo = (fv, to = '-') => (fv || fv === 0 ? fv : to);
/**
* 格式化时间戳
* @param timestamp
@ -201,10 +202,18 @@ export function mostCommonElement(array) {
}, '');
}
// /**
// * 将gps坐标,转换为国测局坐标
// * @param point 经纬度构成的数组[经度, 纬度]
// */
// export function GPS2GCJ(point) {
// return gcoord.transform(point, gcoord.WGS84, gcoord.GCJ02);
// }
/**
* 将gps坐标转换为国测局坐标
* @param point 经纬度构成的数组[经度, 纬度]
*/
export function GPS2GCJ(point) {
return gcoord.transform(point, gcoord.WGS84, gcoord.GCJ02);
}
/**
* 将国测局坐标转换为gps坐标
* @param point 经纬度构成的数组[经度, 纬度]
*/
export function GCJ2GPS(point) {
return gcoord.transform(point, gcoord.GCJ02, gcoord.WGS84);
}

79
src/utils/http.js

@ -7,6 +7,85 @@ import { getToken } from './auth';
// import * as UserInfo from "./userInfo";
// import * as Auth from "./auth";
// // 封装登录函数
// async function loginAndUpdateToken() {
// try {
// // 微信登录获取 code
// const { code } = await Taro.login();
//
// // 发送 code 到服务端换取 Token
// const res = await Taro.request({
// url: 'your_login_api',
// method: 'POST',
// data: { code }
// });
//
// // 存储 Token 和 Refresh Token
// Taro.setStorageSync('access_token', res.data.access_token);
// Taro.setStorageSync('refresh_token', res.data.refresh_token);
// } catch (error) {
// console.error('登录失败:', error);
// Taro.showToast({ title: '登录失效,请重新登录', icon: 'none' });
// Taro.navigateTo({ url: '/pages/login/index' });
// }
// }
// // 添加全局请求拦截器
// Taro.addInterceptor(async (chain) => {
// const requestParams = chain.requestParams;
//
// // 从本地存储获取 Token
// let token = Taro.getStorageSync('access_token');
//
// // 若 Token 不存在,触发登录
// if (!token) {
// await loginAndUpdateToken();
// token = Taro.getStorageSync('access_token');
// }
//
// // 附加 Token 到请求头
// requestParams.header = {
// ...requestParams.header,
// 'Authorization': `Bearer ${token}`
// };
//
// // 发送请求并处理响应
// const response = await chain.proceed(requestParams);
//
// // 若返回 401 未授权错误,尝试刷新 Token
// if (response.statusCode === 401) {
// const newToken = await refreshToken();
// if (newToken) {
// Taro.setStorageSync('access_token', newToken);
// requestParams.header.Authorization = `Bearer ${newToken}`;
// return chain.proceed(requestParams); // 重试原请求
// } else {
// // 刷新失败,跳转登录页
// Taro.navigateTo({ url: '/pages/login/index' });
// return Promise.reject(response);
// }
// }
//
// return response;
// });
//
// // 刷新 Token 的函数
// async function refreshToken() {
// try {
// const res = await Taro.request({
// url: 'your_refresh_token_endpoint',
// method: 'POST',
// data: {
// refresh_token: Taro.getStorageSync('refresh_token')
// }
// });
// return res.data.access_token;
// } catch (error) {
// console.error('刷新 Token 失败:', error);
// return null;
// }
// }
// function judgmentHttpCode(res, reject, resolve) {
// if (res?.statusCode !== 200 || res?.data?.code !== 200) {
// reject(res?.data);

Loading…
Cancel
Save