85 changed files with 10542 additions and 2646 deletions
@ -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" |
|||
|
|||
|
|||
|
@ -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" |
|||
|
File diff suppressed because it is too large
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 86 B |
After Width: | Height: | Size: 7.6 KiB |
@ -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"; |
|||
} |
|||
|
@ -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"; |
|||
} |
|||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -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> |
@ -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> |
@ -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> |
@ -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()]; |
@ -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 {}; |
@ -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()]; |
@ -0,0 +1,11 @@ |
|||
export const STAGE_MAP = { |
|||
spin: { |
|||
text: '自旋', |
|||
}, |
|||
shape8: { |
|||
text: '八字飞行', |
|||
}, |
|||
hover: { |
|||
text: '悬停', |
|||
}, |
|||
} |
@ -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: '操作失败,角速度过低' |
|||
} |
|||
} |
@ -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(); |
@ -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, |
|||
} |
@ -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, |
|||
}; |
@ -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, |
|||
} |
@ -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();
|
@ -0,0 +1,9 @@ |
|||
export default definePageConfig({ |
|||
navigationBarTitleText: '场地管理', |
|||
// disableSwipeBack: true,
|
|||
// enablePullDownRefresh: true,
|
|||
// backgroundTextStyle: 'dark',
|
|||
// navigationStyle: 'custom',
|
|||
// enablePullDownRefresh: false,
|
|||
// disableScroll: false,
|
|||
}) |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -0,0 +1,118 @@ |
|||
/** |
|||
* 地理空间函数库 |
|||
*/ |
|||
import * as math from 'mathjs'; |
|||
import * as turf from '@turf/turf'; |
|||
|
|||
/** |
|||
* 将两个点连成一个向量(并确保落在1、4象限) |
|||
* @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; |
|||
} |
@ -0,0 +1,5 @@ |
|||
export default definePageConfig({ |
|||
navigationBarTitleText: '场地', |
|||
navigationStyle: 'custom', |
|||
pageOrientation: 'landscape' |
|||
}) |
@ -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> |
@ -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; |
@ -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, |
|||
} |
|||
} |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -1,5 +1,5 @@ |
|||
export default { |
|||
navigationBarTitleText: '实践飞行回放', |
|||
navigationStyle: 'default', |
|||
navigationStyle: 'custom', |
|||
pageOrientation: 'landscape' |
|||
}; |
|||
|
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -0,0 +1,118 @@ |
|||
/** |
|||
* 地理空间函数库 |
|||
*/ |
|||
import * as math from 'mathjs'; |
|||
import * as turf from '@turf/turf'; |
|||
|
|||
/** |
|||
* 将两个点连成一个向量(并确保落在1、4象限) |
|||
* @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; |
|||
} |
@ -0,0 +1,5 @@ |
|||
export default definePageConfig({ |
|||
navigationBarTitleText: '学生实时飞行', |
|||
navigationStyle: 'custom', |
|||
pageOrientation: 'landscape' |
|||
}) |
@ -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> |
@ -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; |
@ -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, |
|||
} |
|||
} |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -0,0 +1,118 @@ |
|||
/** |
|||
* 地理空间函数库 |
|||
*/ |
|||
import * as math from 'mathjs'; |
|||
import * as turf from '@turf/turf'; |
|||
|
|||
/** |
|||
* 将两个点连成一个向量(并确保落在1、4象限) |
|||
* @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; |
|||
} |
@ -1,5 +1,5 @@ |
|||
export default definePageConfig({ |
|||
navigationBarTitleText: '监管详情', |
|||
navigationStyle: 'default', |
|||
navigationStyle: 'custom', |
|||
pageOrientation: 'landscape' |
|||
}) |
@ -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; |
@ -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, |
|||
} |
|||
} |
@ -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, |
|||
}; |
|||
}); |
@ -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, |
|||
}; |
|||
}); |
@ -0,0 +1,118 @@ |
|||
/** |
|||
* 地理空间函数库 |
|||
*/ |
|||
import * as math from 'mathjs'; |
|||
import * as turf from '@turf/turf'; |
|||
|
|||
/** |
|||
* 将两个点连成一个向量(并确保落在1、4象限) |
|||
* @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; |
|||
} |
Loading…
Reference in new issue