Browse Source

【init】初始项目

main
xiaosi 4 weeks ago
commit
127695558b
  1. 14
      .editorconfig
  2. 6
      .env.development
  3. 3
      .env.production
  4. 1
      .env.test
  5. 5
      .eslintrc
  6. 8
      .gitignore
  7. 8
      .idea/.gitignore
  8. 7
      .idea/inspectionProfiles/Project_Default.xml
  9. 6
      .idea/misc.xml
  10. 8
      .idea/modules.xml
  11. 9
      .idea/uav-edu-mp.iml
  12. 6
      .idea/vcs.xml
  13. 10
      babel.config.js
  14. 36
      components.d.ts
  15. 8
      config/dev.js
  16. 135
      config/index.js
  17. 36
      config/prod.js
  18. 27710
      package-lock.json
  19. 86
      package.json
  20. 31
      project.config.json
  21. 10
      project.private.config.json
  22. 13
      project.tt.json
  23. 22
      src/app.config.js
  24. 21
      src/app.js
  25. 0
      src/app.less
  26. BIN
      src/assets/deviceIcon.png
  27. BIN
      src/assets/flower.png
  28. BIN
      src/assets/home-marker.png
  29. 3
      src/assets/home-marker.svg
  30. 27
      src/assets/iconfont.css
  31. BIN
      src/assets/iconfont.ttf
  32. BIN
      src/assets/iconfont.woff
  33. BIN
      src/assets/iconfont.woff2
  34. BIN
      src/assets/list.png
  35. BIN
      src/assets/point-marker.png
  36. 3
      src/assets/point-marker.svg
  37. 3
      src/assets/red-marker.svg
  38. BIN
      src/assets/transparent-marker.png
  39. 3
      src/assets/transparent-marker.svg
  40. 100
      src/components/ExamQuestion.vue
  41. 206
      src/components/ExamResult.vue
  42. 205
      src/components/RealTimeData.vue
  43. 222
      src/components/RoutePointInfo.vue
  44. 324
      src/components/SupervisionData.vue
  45. 214
      src/components/SupervisionSideData.vue
  46. 54
      src/components/TabBar.vue
  47. 195
      src/components/TrackPlayback.vue
  48. 96
      src/config/urls.js
  49. 165
      src/core/FormData.js
  50. 296
      src/core/deviceCruise.js
  51. 345
      src/core/mimeMap.js
  52. 17
      src/index.html
  53. 6
      src/pages/evaluation/index.config.js
  54. 621
      src/pages/evaluation/index.vue
  55. 6
      src/pages/flight/index.config.js
  56. 226
      src/pages/flight/index.vue
  57. 5
      src/pages/flightMap/index.config.js
  58. 252
      src/pages/flightMap/index.vue
  59. 1446
      src/pages/flightMap/track.json
  60. 358
      src/pages/flightMap/utils.js
  61. 5
      src/pages/home/index.config.js
  62. 350
      src/pages/home/index.vue
  63. 7
      src/pages/login/index.config.js
  64. 279
      src/pages/login/index.vue
  65. 3
      src/pages/own/index.config.js
  66. 171
      src/pages/own/index.vue
  67. 6
      src/pages/returnTrip/index.config.js
  68. 215
      src/pages/returnTrip/index.vue
  69. 5
      src/pages/returnTripMap/index.config.js
  70. 116
      src/pages/returnTripMap/index.vue
  71. 96
      src/pages/returnTripMap/utils.js
  72. 6
      src/pages/routePlan/index.config.js
  73. 221
      src/pages/routePlan/index.vue
  74. 5
      src/pages/routePlanMap/index.config.js
  75. 433
      src/pages/routePlanMap/index.vue
  76. 5
      src/pages/supervision/index.config.js
  77. 375
      src/pages/supervision/index.vue
  78. 5
      src/pages/supervisionMap/index.config.js
  79. 289
      src/pages/supervisionMap/index.vue
  80. 14
      src/stores/index.js
  81. 138
      src/stores/modules/authStore.js
  82. 61
      src/stores/modules/evaluationStore.js
  83. 49
      src/stores/modules/flightStore.js
  84. 40
      src/stores/modules/returnTripStore.js
  85. 40
      src/stores/modules/routePlanStore.js
  86. 51
      src/stores/modules/supervisionStore.js
  87. 34
      src/utils/auth.js
  88. 6
      src/utils/eventBus.js
  89. 210
      src/utils/helpers.js
  90. 209
      src/utils/http.js
  91. 34
      src/utils/userInfo.js

14
.editorconfig

@ -0,0 +1,14 @@
# http://editorconfig.org
root = true
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 4
max_line_length = 180
[*.md]
trim_trailing_whitespace = false

6
.env.development

@ -0,0 +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"

3
.env.production

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

1
.env.test

@ -0,0 +1 @@
# TARO_APP_ID="测试环境下的小程序 AppID"

5
.eslintrc

@ -0,0 +1,5 @@
// ESLint 检查 .vue 文件需要单独配置编辑器:
// https://eslint.vuejs.org/user-guide/#editor-integrations
{
"extends": ["taro/vue3"]
}

8
.gitignore

@ -0,0 +1,8 @@
dist/
deploy_versions/
.temp/
.rn_temp/
node_modules/
.DS_Store
.swc
*.local

8
.idea/.gitignore

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/inspectionProfiles/Project_Default.xml

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>

6
.idea/misc.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/uav-edu-mp.iml" filepath="$PROJECT_DIR$/.idea/uav-edu-mp.iml" />
</modules>
</component>
</project>

9
.idea/uav-edu-mp.iml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

10
babel.config.js

@ -0,0 +1,10 @@
// babel-preset-taro 更多选项和默认值:
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
module.exports = {
presets: [
['taro', {
framework: 'vue3',
ts: false
}]
]
}

36
components.d.ts

@ -0,0 +1,36 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ExamQuestion: typeof import('./src/components/ExamQuestion.vue')['default']
ExamResult: typeof import('./src/components/ExamResult.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCell: typeof import('@nutui/nutui-taro')['Cell']
NutDialog: typeof import('@nutui/nutui-taro')['Dialog']
NutEmpty: typeof import('@nutui/nutui-taro')['Empty']
NutForm: typeof import('@nutui/nutui-taro')['Form']
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
NutInfiniteloading: typeof import('@nutui/nutui-taro')['Infiniteloading']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutOverlay: typeof import('@nutui/nutui-taro')['Overlay']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRange: typeof import('@nutui/nutui-taro')['Range']
NutTabbar: typeof import('@nutui/nutui-taro')['Tabbar']
NutTabbarItem: typeof import('@nutui/nutui-taro')['TabbarItem']
NutTag: typeof import('@nutui/nutui-taro')['Tag']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
RealTimeData: typeof import('./src/components/RealTimeData.vue')['default']
RoutePointInfo: typeof import('./src/components/RoutePointInfo.vue')['default']
SupervisionData: typeof import('./src/components/SupervisionData.vue')['default']
SupervisionSideData: typeof import('./src/components/SupervisionSideData.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TrackPlayback: typeof import('./src/components/TrackPlayback.vue')['default']
}
}

8
config/dev.js

@ -0,0 +1,8 @@
module.exports = {
env: {
NODE_ENV: '"development"'
},
defineConstants: {},
mini: {},
h5: {}
}

135
config/index.js

@ -0,0 +1,135 @@
import Components from 'unplugin-vue-components/webpack';
import NutUIResolver from '@nutui/auto-import-resolver';
const config = {
projectName: 'canola-tool-mp',
date: '2025-3-14',
designWidth(input) {
if (input?.file?.replace(/\\+/g, '/').indexOf('@nutui') > -1) {
return 375
}
return 750
},
deviceRatio: {
640: 2.34 / 2,
750: 1,
828: 1.81 / 2,
375: 2 / 1
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html'],
defineConstants: {},
copy: {
patterns: [],
options: {}
},
framework: 'vue3',
compiler: {
type: 'webpack5',
prebundle: { enable: false }
},
sass: {
data: `@import "@nutui/nutui-taro/dist/styles/variables.scss";`
},
mini: {
enableExtract: true,
miniCssExtractPluginOption: {
ignoreOrder: true,
},
webpackChain(chain) {
chain.plugin('unplugin-vue-components').use(Components({
resolvers: [
NutUIResolver({
importStyle: 'sass',
taro: true
})
]
})).merge({
module: {
rule: {
mjsScript: {
test: /\.mjs$/,
include: [/pinia/],
use: {
babelLoader: {
loader: require.resolve('babel-loader')
}
}
}
}
}
})
},
postcss: {
pxtransform: {
enable: true,
config: {
// selectorBlackList: ['nut-']
}
},
url: {
enable: true,
config: {
limit: 1, // 设定转换尺寸上限
}
},
cssModules: {
enable: true, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
}
},
h5: {
webpackChain(chain) {
chain.plugin('unplugin-vue-components').use(Components({
resolvers: [
NutUIResolver({
importStyle: 'sass',
taro: true
})
]
})).merge({
module: {
rule: {
mjsScript: {
test: /\.mjs$/,
include: [/pinia/],
use: {
babelLoader: {
loader: require.resolve('babel-loader')
}
}
}
}
}
})
},
publicPath: '/',
staticDirectory: 'static',
esnextModules: ['nutui-taro', 'icons-vue-taro'],
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: true, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
}
}
}
module.exports = function (merge) {
if (process.env.NODE_ENV === 'development') {
return merge({}, config, require('./dev'))
}
return merge({}, config, require('./prod'))
}

36
config/prod.js

@ -0,0 +1,36 @@
module.exports = {
env: {
NODE_ENV: '"production"'
},
defineConstants: {},
mini: {},
h5: {
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
}
}

27710
package-lock.json

File diff suppressed because it is too large

86
package.json

@ -0,0 +1,86 @@
{
"name": "uav-edu-mp",
"version": "1.0.0",
"private": true,
"description": "",
"templateInfo": {
"name": "vue3-NutUI4",
"typescript": false,
"css": "Less",
"framework": "Vue3"
},
"scripts": {
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
"build:tt": "taro build --type tt",
"build:h5": "taro build --type h5",
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:quickapp": "taro build --type quickapp",
"dev:weapp": "npm run build:weapp -- --watch",
"dev:swan": "npm run build:swan -- --watch",
"dev:alipay": "npm run build:alipay -- --watch",
"dev:tt": "npm run build:tt -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch",
"dev:jd": "npm run build:jd -- --watch",
"dev:quickapp": "npm run build:quickapp -- --watch"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"author": "",
"dependencies": {
"@babel/runtime": "^7.7.7",
"@leafer-ui/miniapp": "1.0.7",
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "4.3.13",
"@tarojs/components": "3.6.35",
"@tarojs/helper": "3.6.35",
"@tarojs/plugin-framework-vue3": "3.6.35",
"@tarojs/plugin-html": "3.6.35",
"@tarojs/plugin-platform-alipay": "3.6.35",
"@tarojs/plugin-platform-h5": "3.6.35",
"@tarojs/plugin-platform-jd": "3.6.35",
"@tarojs/plugin-platform-qq": "3.6.35",
"@tarojs/plugin-platform-swan": "3.6.35",
"@tarojs/plugin-platform-tt": "3.6.35",
"@tarojs/plugin-platform-weapp": "3.6.35",
"@tarojs/runtime": "3.6.35",
"@tarojs/shared": "3.6.35",
"@tarojs/taro": "3.6.35",
"@turf/turf": "^7.2.0",
"dayjs": "^1.11.13",
"pinia": "^2.2.6",
"popmotion": "^11.0.5",
"vue": "^3.2.40"
},
"devDependencies": {
"@babel/core": "^7.8.0",
"@nutui/auto-import-resolver": "^1.0.0",
"@tarojs/cli": "3.6.35",
"@tarojs/taro-loader": "3.6.35",
"@tarojs/webpack5-runner": "3.6.35",
"@types/node": "^18.15.11",
"@types/webpack-env": "^1.13.6",
"@vue/babel-plugin-jsx": "^1.0.6",
"@vue/compiler-sfc": "^3.2.40",
"babel-preset-taro": "3.6.35",
"css-loader": "3.4.2",
"eslint": "^8.12.0",
"eslint-config-taro": "3.6.35",
"eslint-plugin-vue": "^8.0.0",
"style-loader": "1.3.0",
"stylelint": "9.3.0",
"ts-node": "^10.9.1",
"typescript": "^4.1.0",
"unplugin-vue-components": "^0.26.0",
"vue-loader": "^17.0.0",
"webpack": "^5.78.0"
}
}

31
project.config.json

@ -0,0 +1,31 @@
{
"miniprogramRoot": "dist/",
"projectname": "uav-edu-mp",
"description": "",
"appid": "wxa55106c0b120722e",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"compileHotReLoad": false,
"postcss": false,
"minified": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.6.5",
"srcMiniprogramRoot": "dist/",
"packOptions": {
"ignore": [],
"include": []
},
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
}
}

10
project.private.config.json

@ -0,0 +1,10 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "uav-edu-mp",
"setting": {
"compileHotReLoad": true,
"bigPackageSizeSupport": true,
"urlCheck": false
},
"libVersion": "3.7.12"
}

13
project.tt.json

@ -0,0 +1,13 @@
{
"miniprogramRoot": "./",
"projectname": "uav-edu-mp",
"description": "",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}

22
src/app.config.js

@ -0,0 +1,22 @@
export default defineAppConfig({
pages: [
'pages/login/index',
'pages/supervisionMap/index',
'pages/supervision/index',
'pages/flightMap/index',
'pages/returnTripMap/index',
'pages/routePlanMap/index',
'pages/home/index',
'pages/flight/index',
'pages/routePlan/index',
'pages/returnTrip/index',
'pages/evaluation/index',
'pages/own/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
},
})

21
src/app.js

@ -0,0 +1,21 @@
import { createApp } from 'vue';
import store from './stores';
// import './app.less';
import './assets/iconfont.css'
// import './common.less';
import { IconFont } from '@nutui/icons-vue-taro';
const App = createApp({
onShow(options) {
console.log('App Show', options);
},
// 入口组件不需要实现 render 方法,即使实现了也会被 taro 所覆盖
})
App.use(store);
App.use(IconFont);
export default App;

0
src/app.less

BIN
src/assets/deviceIcon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/flower.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
src/assets/home-marker.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

3
src/assets/home-marker.svg

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="19" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

27
src/assets/iconfont.css

@ -0,0 +1,27 @@
@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";
}

BIN
src/assets/iconfont.ttf

Binary file not shown.

BIN
src/assets/iconfont.woff

Binary file not shown.

BIN
src/assets/iconfont.woff2

Binary file not shown.

BIN
src/assets/list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

BIN
src/assets/point-marker.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

3
src/assets/point-marker.svg

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="19" fill="#1989fa" stroke="#FFFFFF" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

3
src/assets/red-marker.svg

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="19" fill="#FF0000" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

BIN
src/assets/transparent-marker.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

3
src/assets/transparent-marker.svg

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="19" fill="transparent" />
</svg>

After

Width:  |  Height:  |  Size: 146 B

100
src/components/ExamQuestion.vue

@ -0,0 +1,100 @@
<script setup>
import { ref, defineProps } from 'vue';
const props = defineProps({
isVisible: {
type: Boolean,
default: false
},
questionData: {
type: Object,
default: () => ({
imageUrl: '',
content: ''
})
}
});
const isExpanded = ref(false);
//
watch(() => props.isVisible, (newVal) => {
if (newVal) {
setTimeout(() => {
isExpanded.value = true;
}, 50);
} else {
isExpanded.value = false;
}
});
</script>
<template>
<div :class="[s.root, { 'is-expanded': isExpanded }]" v-if="isVisible">
<div class="title">
<div style="display: flex; align-items: center; gap: 4px;">
<t-icon name="file" />
<div>考题</div>
</div>
</div>
<div class="content">
<div class="question-image" v-if="questionData.imageUrl">
<img :src="questionData.imageUrl" alt="考题图片" />
</div>
<div class="requirements" v-html="questionData.content"></div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 80%;
max-width: 400px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
color: #fff;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
z-index: 100;
display: flex;
flex-direction: column;
&.is-expanded {
transform: translateX(0);
}
:global {
.title {
padding: 16px;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.content {
flex: 1;
padding: 16px;
overflow-y: auto;
.question-image {
margin-bottom: 16px;
img {
width: 100%;
border-radius: 8px;
}
}
.requirements {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
}
}
}
</style>

206
src/components/ExamResult.vue

@ -0,0 +1,206 @@
<script setup>
import { ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
const props = defineProps({
isPassed: {
type: Boolean,
required: true
},
errorPoints: {
type: Array,
default: () => [
{
current: '85°',
qualified: '90°',
message: '方位角偏离预定航线过大'
},
{
current: '120米',
qualified: '100米',
message: '飞行高度超出安全范围'
},
{
current: '12米/秒',
qualified: '8米/秒',
message: '飞行速度过快,不符合考试要求'
}
]
}
});
const isVisible = ref(true);
</script>
<template>
<div :class="[s.root, { [s.hidden]: !isVisible }]">
<div class="toggle-btn" :class="{ 'is-active': !isVisible }" @click="() => isVisible = !isVisible">
<Right v-if="isVisible" style="font-size: 12px;" />
<Left style="font-size: 12px;" v-else />
</div>
<div class="title">
<div style="display: flex; align-items: center; gap: 4px;">
<div>考试结果</div>
</div>
</div>
<div class="content">
<div class="result-info">
<div class="status" :class="{ 'passed': isPassed, 'failed': !isPassed }">
{{ isPassed ? '通过' : '未通过' }}
</div>
<div v-if="!isPassed && errorPoints.length > 0" class="error-points">
<div class="error-title">错误点位信息</div>
<div v-for="(point, index) in errorPoints" :key="index" class="error-item">
<div class="error-detail">
<div class="error-row">
<span class="label">当前值</span>
<span class="value">{{ point.current }}</span>
</div>
<div class="error-row">
<span class="label">合格值</span>
<span class="value">{{ point.qualified }}</span>
</div>
</div>
<div class="error-message">{{ point.message }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
right: 5px;
top: 5px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 8px;
color: white;
font-size: 10px;
width: 12vw;
min-width: 140px;
max-height: 80vh;
// overflow: auto;
z-index: 2;
transition: transform 0.3s ease;
&.hidden {
transform: translateX(calc(100% + 5px));
}
:global {
.toggle-btn {
width: 16px;
height: 16px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 0;
top: 20px;
transform: translateX(-100%);
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px 4px 8px;
font-weight: 500;
}
.content {
max-height: 60vh;
overflow: auto;
padding: 6px;
.result-info {
.status {
font-size: 12px;
padding: 6px 8px;
border-radius: 4px;
font-weight: bold;
text-align: center;
// margin-bottom: 8px;
&.passed {
background-color: rgba(0, 255, 0, 0.2);
color: #52c41a;
}
&.failed {
background-color: rgba(255, 0, 0, 0.2);
color: #ff4d4f;
}
}
.error-message {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
line-height: 1.4;
text-align: center;
word-break: break-all;
}
.error-points {
margin-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 8px;
.error-title {
font-weight: 500;
margin-bottom: 6px;
color: #ff6b6b;
}
.error-item {
background-color: rgba(255, 107, 107, 0.1);
padding: 6px;
border-radius: 4px;
margin-bottom: 6px;
.error-detail {
margin-bottom: 6px;
.error-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
.label {
color: #ff9999;
font-size: 10px;
}
.value {
font-size: 10px;
color: #fff;
}
}
}
.error-message {
color: #ff9999;
font-size: 10px;
line-height: 1.4;
}
}
}
}
}
}
}
</style>

205
src/components/RealTimeData.vue

@ -0,0 +1,205 @@
<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 toggleVisibility = () => {
// isVisible.value = !isVisible.value;
// };
// const showTip = ref(true);
// const toggleBtnClass = computed(() => {
// return {
// 'toggle-btn': true,
// 'is-active': !isVisible.value
// };
// });
const info = computed(() => deviceCruise.timelyData);
</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="text-row">
<span class="label">时间</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ formatTime(info.timestamp) }}</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.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" v-if="(info.xspeed === 0) || info.xspeed">
<span class="label">水平速度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.xspeed, 2) }}</small>
<span class="unit">/</span>
</div>
</span>
</div>
<div class="text-row" v-if="(info.velocity === 0) || info.velocity">
<span class="label">速度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.velocity, 2) }}</small>
<span class="unit">千米/小时</span>
</div>
</span>
</div>
<div class="text-row" v-if="(info.breadth === 0) || info.breadth">
<span class="label">幅宽</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.breadth, 2) }}</small>
<span class="unit"></span>
</div>
</span>
</div>
<div class="text-row" v-if="(info.deep === 0) || info.deep">
<span class="label">耕深</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ toFixed(info.deep, 2) }}</small>
<span class="unit">厘米</span>
</div>
</span>
</div>
<div class="text-row" v-if="(info.seeding === 0) || info.seeding">
<span class="label">播种速度</span>
<span class="value">
<div class="value-unit">
<small class="value">{{ info.seeding }}</small>
<span class="unit">/s</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>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 5px;
top: 5px;
background-color: rgba(0, 0, 0, 0.6);
padding: 8px;
border-radius: 8px;
color: white;
font-size: 11px;
width: fit-content;
min-width: 120px;
z-index: 2;
transition: transform 0.3s ease;
&.hidden {
transform: translateX(calc(-100% - 5px));
}
:global {
.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);
}
}
}
}
:global {
.text-row {
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 2px;
&:last-child {
margin-bottom: 0;
}
.label {
min-width: 40px;
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;
}
}
}
}
}
</style>

222
src/components/RoutePointInfo.vue

@ -0,0 +1,222 @@
<script setup>
import { computed, ref } from 'vue';
import { Left, Right } from '@nutui/icons-vue-taro';
import { toFixed } from '../utils/helpers';
const isVisible = ref(true);
const pointInfoList = computed(() => [
{
index: 1,
angle: 0,
distance: 90,
height: 50,
stayTime: 0
},
{
index: 2,
angle: 135,
distance: 70,
height: 60,
stayTime: 2
},
{
index: 3,
angle: 225,
distance: 80,
height: 45,
stayTime: 3
},
{
index: 4,
angle: 315,
distance: 100,
height: 55,
stayTime: 2
}
]);
const props = defineProps({
// isVisible: {
// type: Boolean,
// default: true
// },
pointInfo: {
type: Object,
required: true,
default: () => ({
index: 1,
azimuth: 0,
distance: 0,
height: 0,
stayTime: 0
})
}
});
// const emit = defineEmits(['update:isVisible']);
// const toggleVisibility = () => {
// emit('update:isVisible', !props.isVisible);
// };
</script>
<template>
<div :class="[s.root, { [s.hidden]: !isVisible }]">
<div class="title">
<div style="display: flex; align-items: center; gap: 4px;">
<div>航线点信息</div>
</div>
<div class="toggle-btn" :class="{ '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="content">
<div class="route-point-info">
<div class="point-card" v-for="(item, index) in pointInfoList" :key="index">
<div class="point-header">
<div class="point-index">{{ index + 1 }}</div>
</div>
<div class="point-details">
<div class="detail-row">
<span class="label">方位角</span>
<span class="value">{{ toFixed(item.angle, 1) }}°</span>
</div>
<div class="detail-row">
<span class="label">相对距离</span>
<span class="value">{{ toFixed(item.distance, 2) }}</span>
</div>
<div class="detail-row">
<span class="label">高度</span>
<span class="value">{{ toFixed(item.height, 2) }}</span>
</div>
<div class="detail-row">
<span class="label">停留时长</span>
<span class="value">{{ item.stayTime }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 5px;
top: 5px;
background-color: rgba(0, 0, 0, 0.85);
border-radius: 8px;
color: white;
font-size: 10px;
width: fit-content;
min-width: 17vw;
z-index: 2;
transition: transform 0.3s ease;
&.hidden {
transform: translateX(calc(-100% - 10px));
}
:global {
.title {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px 4px 8px;
}
.toggle-btn {
width: 20px;
height: 20px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.6);
// border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
// transition: all 0.3s ease;
position: absolute;
right: 0;
top: 20px;
transform: translateX(100%);
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&.is-active {
background-color: rgba(0, 0, 0, 0.9);
}
}
.content {
padding: 6px;
.route-point-info {
max-height: 60vh;
overflow: auto;
.point-card {
background-color: rgba(56, 56, 56, 0.9);
border-radius: 8px;
padding: 8px;
display: flex;
align-items: flex-start;
gap: 3px;
.point-header {
display: flex;
align-items: center;
.point-index {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(254, 254, 254, 0.4);
background-color: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
}
}
.point-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
width: 100%;
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
white-space: nowrap;
gap: 4px;
line-height: 1.2;
.label {
font-size: 9px;
color: rgba(255, 255, 255, 0.6);
min-width: 20px;
}
.value {
font-size: 9px;
color: rgba(255, 255, 255, 0.95);
text-align: right;
}
}
}
& + .point-card {
margin-top: 8px;
}
}
}
}
}
}
</style>

324
src/components/SupervisionData.vue

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

214
src/components/SupervisionSideData.vue

@ -0,0 +1,214 @@
<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>

54
src/components/TabBar.vue

@ -0,0 +1,54 @@
<script setup>
import { eventCenter, getCurrentInstance, reLaunch } from '@tarojs/taro'
import {onMounted, ref, h, computed} from "vue";
import { Category, My, Eye, Horizontal } from '@nutui/icons-vue-taro';
import { useAuthStore } from "../stores";
import { storeToRefs } from 'pinia';
const { isStudent } = storeToRefs(useAuthStore());
const current = ref(0);
const tabList = computed(() => [
...(isStudent.value ? [
{ title: '首页', icon: h(Category), path: '/pages/home/index' },
{ title: '监管', icon: h(Eye), path: '/pages/supervision/index' },
{ title: '我的', icon: h(My), path: '/pages/own/index' }
] : [
{ title: '监管', icon: h(Eye), path: '/pages/supervision/index' },
{ title: '记录', icon: h(Horizontal), path: '/pages/home/index' },
{ title: '我的', icon: h(My), path: '/pages/own/index' }
]),
]);
const emit = defineEmits(['change']);
function handleClick(_, index) {
const item = tabList.value[index];
emit('change', item);
if (item.path) {
reLaunch({ url: item.path })
current.value = index;
}
}
onMounted(() => {
eventCenter.on(getCurrentInstance().router.onShow, () => {
const page = getCurrentInstance().router.path;
if (!page) return;
const index = tabList.value.findIndex(item => item.path === page);
if (index !== -1) current.value = index;
});
})
</script>
<template>
<nut-tabbar v-model="current" bottom safe-area-inset-bottom placeholder unactive-color="#7d7e80" active-color="#1989fa" @tab-switch="handleClick">
<nut-tabbar-item v-for="(item, index) in tabList" :key="index" :tab-title="item.title" :icon="item.icon">
</nut-tabbar-item>
</nut-tabbar>
</template>
<style lang="less" module="s">
</style>

195
src/components/TrackPlayback.vue

@ -0,0 +1,195 @@
<script setup>
import { computed, ref } from 'vue';
import deviceCruise from '../core/deviceCruise';
import { PlayStop, PlayStart, Refresh } from '@nutui/icons-vue-taro';
//
const ready = computed(() => deviceCruise.ready);
const speedRate = computed(() => deviceCruise.speedRate);
const totalTime = computed(() => deviceCruise.totalTime);
const currentTime = computed({
get() {
return deviceCruise.elapsedMs;
},
set(val) {
// console.log('aaaaa', val);
// deviceCruise.handlePause();
deviceCruise.setCurrentTime(val);
},
});
const showPlayButton = computed(() => deviceCruise.isPaused || deviceCruise.isStopped);
//
function formatTime(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
//
const currentTimeDisplay = computed(() => formatTime(deviceCruise.elapsedMs));
const totalTimeDisplay = computed(() => formatTime(totalTime.value));
//
function onChangeSpeedRate() {
const newSpeedRate = speedRate.value >= 8 ? 1 : speedRate.value * 2;
deviceCruise.setSpeedRate(newSpeedRate);
}
function onPlay() {
deviceCruise.handlePlay();
}
function onPause() {
deviceCruise.handlePause();
}
function onStop() {
deviceCruise.handleStop();
}
</script>
<template>
<view :class="s.root">
<view class="slider-container">
<nut-range
v-model="currentTime"
hidden-range
:hidden-tag="true"
:max="totalTime"
:min="0"
:step="1"
/>
<view class="time-display">
<text>{{ currentTimeDisplay }}</text>
<text>{{ totalTimeDisplay }}</text>
</view>
</view>
<view class="controls">
<view class="btn" @click="onChangeSpeedRate" style="color: #fff;">
{{ speedRate }} x
</view>
<view class="btn" v-if="showPlayButton" @click="onPlay">
<PlayStart />
</view>
<view class="btn" v-else @click="onPause">
<PlayStop />
</view>
<view class="btn" @click="onStop">
<Refresh />
</view>
</view>
</view>
</template>
<style lang="less" module="s">
.root {
pointer-events: visible;
position: absolute;
left: 10rpx;
right: 10rpx;
bottom: 5rpx;
z-index: 999;
background: rgba(0, 0, 0, 0.5);
padding: 10rpx 15rpx 10rpx 15rpx;
border-radius: 8rpx;
display: flex;
// flex-direction: row;
align-items: center;
gap: 20rpx;
:global {
.slider-container {
flex: 4;
.nut-range-button {
width: 16rpx;
height: 16rpx;
}
}
.time-display {
display: flex;
justify-content: space-between;
font-size: 16rpx;
color: #fff;
margin-top: 5rpx;
}
.controls {
// flex: 2;
display: flex;
align-items: center;
// justify-content: flex-end;
gap: 10rpx;
.btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background-color: #0069ca!important;
min-width: 30rpx;
font-size: 16rpx;
height: 40rpx;
width: 40rpx;
border-radius: 8rpx;
&:active {
background-color: #0056b3!important;
}
.nut-icon {
//width: 12px;
//height: 12px;
}
}
}
.play-controls {
// display: flex;
// gap: 10rpx;
}
// .nut-button {
// min-width: 30rpx;
// height: 30rpx;
// padding: 0 6rpx;
// display: flex;
// align-items: center;
// justify-content: center;
// color: #fff;
// // background-color: #1890ff !important;
// }
.iconfont {
font-size: 16rpx;
}
.time-display {
display: flex;
justify-content: space-between;
font-size: 16rpx;
color: #fff;
margin-top: 8rpx;
}
.icon-play:before {
content: '\e87c';
}
.icon-pause:before {
content: '\e87d';
}
.icon-stop:before {
content: '\e87e';
}
}
}
</style>

96
src/config/urls.js

@ -0,0 +1,96 @@
/**
* 接口地址列表
*/
import { buildURL } from '../utils/helpers';
const { TARO_APP_API: BASE_URL } = process.env; // 获取环境变量
// const { TARO_APP_API: BASE_URL } = import.meta; // 获取环境变量
// 上传
// export const UPLOAD = `${BASE_URL}/v1/files/upload`;
export const UPLOAD = `http://192.168.10.23:9762/v1/files/upload`;
// 用户登录
export const LOGIN_WITH_PASSWORD = `${BASE_URL}/user/token`;
export const WECHAT_AUTH_URL = `${BASE_URL}/wechat/mini/auth`;
export const GET_WECHAT_USERINFO = `${BASE_URL}/wechat/mini/getWxUserInfo`;
//学生信息
export const UPDATE_STUDENT = (id) => buildURL(`${BASE_URL}/v1/students/{id}`, id);
//人员信息
export const UPDATE_MEMBER = (id) => buildURL(`${BASE_URL}/v1/users/{id}`, id);
// 讲评管理
// export const GET_EVALUATION_LIST = `${BASE_URL}/v1/evaluations`;
// export const CREATE_EVALUATION = `${BASE_URL}/v1/evaluations`;
// export const UPDATE_EVALUATION = `${BASE_URL}/v1/evaluations`;
// export const GET_EVALUATION_DETAIL = (id) => buildURL(`${BASE_URL}/v1/evaluations/{id}`, id);
export const GET_EVALUATION_LIST = `http://192.168.10.23:9762/v1/evaluations`;
export const CREATE_EVALUATION = `http://192.168.10.23:9762/v1/evaluations`;
export const UPDATE_EVALUATION = `http://192.168.10.23:9762/v1/evaluations`;
export const GET_EVALUATION_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/evaluations/{id}`, id);
export const REPLY_EVALUATION = 'http://192.168.10.23:9762/v1/evaluations/reply';
// 实践飞行管理
// export const GET_FLIGHT_LIST = `${BASE_URL}/v1/flightRecords`;
// export const GET_FLIGHT_DETAIL = (id) => buildURL(`${BASE_URL}/v1/flightRecords/{id}`, id);
// export const GET_FLIGHT_TRACKS = (id) => buildURL(`${BASE_URL}/v1/flightRecords/{id}/tracks`, id);
export const GET_FLIGHT_LIST = `http://192.168.10.23:9762/v1/flightRecords`;
export const GET_FLIGHT_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/flightRecords/{id}`, id);
export const GET_FLIGHT_TRACKS = (id) => buildURL(`http://192.168.10.23:9762/v1/flightRecords/{id}/tracks`, id);
// 航线管理
// export const GET_ROUTE_PLAN_LIST = `${BASE_URL}/v1/routePlans`;
// export const GET_ROUTE_PLAN_DETAIL = (id) => buildURL(`${BASE_URL}/v1/routePlans/{id}`, id);
export const GET_ROUTE_PLAN_LIST = `http://192.168.10.23:9762/v1/routePlans`;
export const GET_ROUTE_PLAN_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/routePlans/{id}`, id);
// 应急返航管理
// export const GET_RETURN_TRIP_LIST = `${BASE_URL}/v1/returnTrips`;
// export const GET_RETURN_TRIP_DETAIL = (id) => buildURL(`${BASE_URL}/v1/returnTrips/{id}`, id);
export const GET_RETURN_TRIP_LIST = `http://192.168.10.23:9762/v1/returnTrips`;
export const GET_RETURN_TRIP_DETAIL = (id) => buildURL(`http://192.168.10.23:9762/v1/returnTrips/{id}`, id);
// 用户信息
export const GET_USER_INFO = `${BASE_URL}/user/userInfo`;
// 识别记录
export const CREATE_RECORD = `${BASE_URL}/record/createRecord`;
// export const CREATE_RECORD = `${BASE_URL}/record/upload`;
export const GET_RECORD_LIST = `${BASE_URL}/record/page`;
export const DELETE_RECORD = `${BASE_URL}/record/delete/{recordId}`;
export const UPDATE_RECORD = `${BASE_URL}/record/update`;
export const GET_RECORD_DETAIL = `${BASE_URL}/record/getRecordDetail/{recordId}`;
export const UPDATE_RECORD_DETAIL = `${BASE_URL}/record/updateRecordContent`;
export const EXPORT_RECORD = `${BASE_URL}/record/export/{recordId}`;
export const SET_SCALE = `${BASE_URL}/record/scale`;
// 任务
export const GET_TASK_LIST = `${BASE_URL}/record/tasks`;
export const GET_TASK_PROGRESS = `${BASE_URL}/record/taskProgress/{taskId}`;
// 模型列表
export const GET_MODEL_LIST = `${BASE_URL}/record/models`;
export const UPDATE_USER = `${BASE_URL}/user/update`;
export const SEARCH_REGION_FARMER = `${BASE_URL}/user/listFarmerByRegionCode`;
export const UPDATE_FARMER = `${BASE_URL}/user/updateFarmer`;
// 作业
export const GET_WORK_RECORD_LIST = `${BASE_URL}/job/page`;
export const GET_TRACK_LIST = `${BASE_URL}/job/tracks/{jobId}`;
export const GET_WORK_TYPE_LIST = `${BASE_URL}/job/types`;
export const GET_WORKING_DETAIL = `${BASE_URL}/job/inWorking`;
export const UPDATE_WORK_STATUS = `${BASE_URL}/job/status`;
export const CREATE_WORK = `${BASE_URL}/job/start`;
// 设备
export const GET_DEVICE_LIST = `${BASE_URL}/device/page`;
export const GET_ONLINE_DEVICES = `${BASE_URL}/device/onlineAndRound`;
// 地区
export const GET_REGION_CHILDREN = `${BASE_URL}/region/getChildren`;

165
src/core/FormData.js

@ -0,0 +1,165 @@
// 文件 formData.js
import mimeMap from './mimeMap';
import Taro from "@tarojs/taro";
function FormData(){
let fileManager = Taro.getFileSystemManager();
let data = {};
let files = [];
this.clearCacheData = () => {
data = {};
files = [];
}
this.append = (name, value)=>{
data[name] = value;
return true;
}
this.appendFile = (name, path, fileName)=>{
let buffer = fileManager.readFileSync(path);
if(Object.prototype.toString.call(buffer).indexOf("ArrayBuffer") < 0){
return false;
}
if(!fileName){
fileName = getFileNameFromPath(path);
}
files.push({
name: name,
buffer: buffer,
fileName: fileName
});
return true;
}
this.getData = ()=>convert(data, files)
}
function getFileNameFromPath(path){
let idx=path.lastIndexOf("/");
return path.substr(idx+1);
}
function convert(data, files){
let boundaryKey = 'wxmpFormBoundary' + randString(); // 数据分割符,一般是随机的字符串
let boundary = '--' + boundaryKey;
let endBoundary = boundary + '--';
let postArray = [];
//拼接参数
if(data && Object.prototype.toString.call(data) == "[object Object]"){
for(let key in data){
postArray = postArray.concat(formDataArray(boundary, key, data[key]));
}
}
//拼接文件
if(files && Object.prototype.toString.call(files) == "[object Array]"){
for(let i in files){
let file = files[i];
postArray = postArray.concat(formDataArray(boundary, file.name, file.buffer, file.fileName));
}
}
//结尾
let endBoundaryArray = [];
endBoundaryArray.push(...endBoundary.toUtf8Bytes());
postArray = postArray.concat(endBoundaryArray);
return {
contentType: 'multipart/form-data; boundary=' + boundaryKey,
buffer: new Uint8Array(postArray).buffer
}
}
function randString() {
let res = "";
for (let i = 0; i < 17; i++) {
let n = parseInt(Math.random() * 62);
if (n <= 9) {
res += n;
}
else if (n <= 35) {
res += String.fromCharCode(n + 55);
}
else {
res += String.fromCharCode(n + 61);
}
}
return res;
}
function formDataArray(boundary, name, value, fileName){
let dataString = '';
let isFile = !!fileName;
dataString += boundary + '\r\n';
dataString += 'Content-Disposition: form-data; name="' + name + '"';
if (isFile){
dataString += '; filename="' + fileName + '"' + '\r\n';
dataString += 'Content-Type: ' + getFileMime(fileName) + '\r\n\r\n';
}
else{
dataString += '\r\n\r\n';
dataString += value;
}
let dataArray = [];
dataArray.push(...dataString.toUtf8Bytes());
if (isFile) {
let fileArray = new Uint8Array(value);
dataArray = dataArray.concat(Array.prototype.slice.call(fileArray));
}
dataArray.push(..."\r".toUtf8Bytes());
dataArray.push(..."\n".toUtf8Bytes());
return dataArray;
}
function getFileMime(fileName){
let idx = fileName.lastIndexOf(".");
let mime = mimeMap[fileName.substr(idx)];
return mime?mime:"application/octet-stream"
}
String.prototype.toUtf8Bytes = function(){
var str = this;
var bytes = [];
for (var i = 0; i < str.length; i++) {
bytes.push(...str.utf8CodeAt(i));
if (str.codePointAt(i) > 0xffff) {
i++;
}
}
return bytes;
}
String.prototype.utf8CodeAt = function(i) {
var str = this;
var out = [], p = 0;
var c = str.charCodeAt(i);
if (c < 128) {
out[p++] = c;
} else if (c < 2048) {
out[p++] = (c >> 6) | 192;
out[p++] = (c & 63) | 128;
} else if (
((c & 0xFC00) == 0xD800) && (i + 1) < str.length &&
((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) {
// Surrogate Pair
c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF);
out[p++] = (c >> 18) | 240;
out[p++] = ((c >> 12) & 63) | 128;
out[p++] = ((c >> 6) & 63) | 128;
out[p++] = (c & 63) | 128;
} else {
out[p++] = (c >> 12) | 224;
out[p++] = ((c >> 6) & 63) | 128;
out[p++] = (c & 63) | 128;
}
return out;
};
export default FormData;

296
src/core/deviceCruise.js

@ -0,0 +1,296 @@
/**
* 设备按轨迹巡航
*/
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();

345
src/core/mimeMap.js

@ -0,0 +1,345 @@
export default {
"0.001": "application/x-001",
"0.323": "text/h323",
"0.907": "drawing/907",
".acp": "audio/x-mei-aac",
".aif": "audio/aiff",
".aiff": "audio/aiff",
".asa": "text/asa",
".asp": "text/asp",
".au": "audio/basic",
".awf": "application/vnd.adobe.workflow",
".bmp": "application/x-bmp",
".c4t": "application/x-c4t",
".cal": "application/x-cals",
".cdf": "application/x-netcdf",
".cel": "application/x-cel",
".cg4": "application/x-g4",
".cit": "application/x-cit",
".cml": "text/xml",
".cmx": "application/x-cmx",
".crl": "application/pkix-crl",
".csi": "application/x-csi",
".cut": "application/x-cut",
".dbm": "application/x-dbm",
".dcd": "text/xml",
".der": "application/x-x509-ca-cert",
".dib": "application/x-dib",
".doc": "application/msword",
".drw": "application/x-drw",
".dwf": "Model/vnd.dwf",
".dwg": "application/x-dwg",
".dxf": "application/x-dxf",
".emf": "application/x-emf",
".ent": "text/xml",
".eps": "application/x-ps",
".etd": "application/x-ebx",
".fax": "image/fax",
".fif": "application/fractals",
".frm": "application/x-frm",
".gbr": "application/x-gbr",
".gif": "image/gif",
".gp4": "application/x-gp4",
".hmr": "application/x-hmr",
".hpl": "application/x-hpl",
".hrf": "application/x-hrf",
".htc": "text/x-component",
".html": "text/html",
".htx": "text/html",
".ico": "image/x-icon",
".iff": "application/x-iff",
".igs": "application/x-igs",
".img": "application/x-img",
".isp": "application/x-internet-signup",
".java": "java/*",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "application/x-jpg",
".jsp": "text/html",
".lar": "application/x-laplayer-reg",
".lavs": "audio/x-liquid-secure",
".lmsff": "audio/x-la-lms",
".ltr": "application/x-ltr",
".m2v": "video/x-mpeg",
".m4e": "video/mpeg4",
".man": "application/x-troff-man",
".mdb": "application/msaccess",
".mfp": "application/x-shockwave-flash",
".mhtml": "message/rfc822",
".mid": "audio/mid",
".mil": "application/x-mil",
".mnd": "audio/x-musicnet-download",
".mocha": "application/x-javascript",
".mp1": "audio/mp1",
".mp2v": "video/mpeg",
".mp4": "video/mpeg4",
".mpd": "application/vnd.ms-project",
".mpeg": "video/mpg",
".mpga": "audio/rn-mpeg",
".mps": "video/x-mpeg",
".mpv": "video/mpg",
".mpw": "application/vnd.ms-project",
".mtx": "text/xml",
".net": "image/pnetvue",
".nws": "message/rfc822",
".out": "application/x-out",
".p12": "application/x-pkcs12",
".p7c": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".pc5": "application/x-pc5",
".pcl": "application/x-pcl",
".pdf": "application/pdf",
".pdx": "application/vnd.adobe.pdx",
".pgl": "application/x-pgl",
".pko": "application/vnd.ms-pki.pko",
".plg": "text/html",
".plt": "application/x-plt",
".png": "application/x-png",
".ppa": "application/vnd.ms-powerpoint",
".pps": "application/vnd.ms-powerpoint",
".ppt": "application/x-ppt",
".prf": "application/pics-rules",
".prt": "application/x-prt",
".ps": "application/postscript",
".pwz": "application/vnd.ms-powerpoint",
".ra": "audio/vnd.rn-realaudio",
".ras": "application/x-ras",
".rdf": "text/xml",
".red": "application/x-red",
".rjs": "application/vnd.rn-realsystem-rjs",
".rlc": "application/x-rlc",
".rm": "application/vnd.rn-realmedia",
".rmi": "audio/mid",
".rmm": "audio/x-pn-realaudio",
".rms": "application/vnd.rn-realmedia-secure",
".rmx": "application/vnd.rn-realsystem-rmx",
".rp": "image/vnd.rn-realpix",
".rsml": "application/vnd.rn-rsml",
".rtf": "application/msword",
".rv": "video/vnd.rn-realvideo",
".sat": "application/x-sat",
".sdw": "application/x-sdw",
".slb": "application/x-slb",
".slk": "drawing/x-slk",
".smil": "application/smil",
".snd": "audio/basic",
".sor": "text/plain",
".spl": "application/futuresplash",
".ssm": "application/streamingmedia",
".stl": "application/vnd.ms-pki.stl",
".sty": "application/x-sty",
".swf": "application/x-shockwave-flash",
".tg4": "application/x-tg4",
".tif": "image/tiff",
".tiff": "image/tiff",
".top": "drawing/x-top",
".tsd": "text/xml",
".uin": "application/x-icq",
".vcf": "text/x-vcard",
".vdx": "application/vnd.visio",
".vpg": "application/x-vpeg005",
".vsd": "application/x-vsd",
".vst": "application/vnd.visio",
".vsw": "application/vnd.visio",
".vtx": "application/vnd.visio",
".wav": "audio/wav",
".wb1": "application/x-wb1",
".wb3": "application/x-wb3",
".wiz": "application/msword",
".wk4": "application/x-wk4",
".wks": "application/x-wks",
".wma": "audio/x-ms-wma",
".wmf": "application/x-wmf",
".wmv": "video/x-ms-wmv",
".wmz": "application/x-ms-wmz",
".wpd": "application/x-wpd",
".wpl": "application/vnd.ms-wpl",
".wr1": "application/x-wr1",
".wrk": "application/x-wrk",
".ws2": "application/x-ws",
".wsdl": "text/xml",
".xdp": "application/vnd.adobe.xdp",
".xfd": "application/vnd.adobe.xfd",
".xhtml": "text/html",
".xls": "application/x-xls",
".xml": "text/xml",
".xq": "text/xml",
".xquery": "text/xml",
".xsl": "text/xml",
".xwd": "application/x-xwd",
".sis": "application/vnd.symbian.install",
".x_t": "application/x-x_t",
".apk": "application/vnd.android.package-archive",
"0.301": "application/x-301",
"0.906": "application/x-906",
".a11": "application/x-a11",
".ai": "application/postscript",
".aifc": "audio/aiff",
".anv": "application/x-anv",
".asf": "video/x-ms-asf",
".asx": "video/x-ms-asf",
".avi": "video/avi",
".biz": "text/xml",
".bot": "application/x-bot",
".c90": "application/x-c90",
".cat": "application/vnd.ms-pki.seccat",
".cdr": "application/x-cdr",
".cer": "application/x-x509-ca-cert",
".cgm": "application/x-cgm",
".class": "java/*",
".cmp": "application/x-cmp",
".cot": "application/x-cot",
".crt": "application/x-x509-ca-cert",
".css": "text/css",
".dbf": "application/x-dbf",
".dbx": "application/x-dbx",
".dcx": "application/x-dcx",
".dgn": "application/x-dgn",
".dll": "application/x-msdownload",
".dot": "application/msword",
".dtd": "text/xml",
".dwf": "application/x-dwf",
".dxb": "application/x-dxb",
".edn": "application/vnd.adobe.edn",
".eml": "message/rfc822",
".epi": "application/x-epi",
".eps": "application/postscript",
".exe": "application/x-msdownload",
".fdf": "application/vnd.fdf",
".fo": "text/xml",
".g4": "application/x-g4",
".tif": "image/tiff",
".gl2": "application/x-gl2",
".hgl": "application/x-hgl",
".hpg": "application/x-hpgl",
".hqx": "application/mac-binhex40",
".hta": "application/hta",
".htm": "text/html",
".htt": "text/webviewhtml",
".icb": "application/x-icb",
".ico": "application/x-ico",
".ig4": "application/x-g4",
".iii": "application/x-iphone",
".ins": "application/x-internet-signup",
".IVF": "video/x-ivf",
".jfif": "image/jpeg",
".jpe": "application/x-jpe",
".jpg": "image/jpeg",
".js": "application/x-javascript",
".la1": "audio/x-liquid-file",
".latex": "application/x-latex",
".lbm": "application/x-lbm",
".ls": "application/x-javascript",
".m1v": "video/x-mpeg",
".m3u": "audio/mpegurl",
".mac": "application/x-mac",
".math": "text/xml",
".mdb": "application/x-mdb",
".mht": "message/rfc822",
".mi": "application/x-mi",
".midi": "audio/mid",
".mml": "text/xml",
".mns": "audio/x-musicnet-stream",
".movie": "video/x-sgi-movie",
".mp2": "audio/mp2",
".mp3": "audio/mp3",
".mpa": "video/x-mpg",
".mpe": "video/x-mpeg",
".mpg": "video/mpg",
".mpp": "application/vnd.ms-project",
".mpt": "application/vnd.ms-project",
".mpv2": "video/mpeg",
".mpx": "application/vnd.ms-project",
".mxp": "application/x-mmxp",
".nrf": "application/x-nrf",
".odc": "text/x-ms-odc",
".p10": "application/pkcs10",
".p7b": "application/x-pkcs7-certificates",
".p7m": "application/pkcs7-mime",
".p7s": "application/pkcs7-signature",
".pci": "application/x-pci",
".pcx": "application/x-pcx",
".pdf": "application/pdf",
".pfx": "application/x-pkcs12",
".pic": "application/x-pic",
".pl": "application/x-perl",
".pls": "audio/scpls",
".png": "image/png",
".pot": "application/vnd.ms-powerpoint",
".ppm": "application/x-ppm",
".ppt": "application/vnd.ms-powerpoint",
".pr": "application/x-pr",
".prn": "application/x-prn",
".ps": "application/x-ps",
".ptn": "application/x-ptn",
".r3t": "text/vnd.rn-realtext3d",
".ram": "audio/x-pn-realaudio",
".rat": "application/rat-file",
".rec": "application/vnd.rn-recording",
".rgb": "application/x-rgb",
".rjt": "application/vnd.rn-realsystem-rjt",
".rle": "application/x-rle",
".rmf": "application/vnd.adobe.rmf",
".rmj": "application/vnd.rn-realsystem-rmj",
".rmp": "application/vnd.rn-rn_music_package",
".rmvb": "application/vnd.rn-realmedia-vbr",
".rnx": "application/vnd.rn-realplayer",
".rpm": "audio/x-pn-realaudio-plugin",
".rt": "text/vnd.rn-realtext",
".rtf": "application/x-rtf",
".sam": "application/x-sam",
".sdp": "application/sdp",
".sit": "application/x-stuffit",
".sld": "application/x-sld",
".smi": "application/smil",
".smk": "application/x-smk",
".sol": "text/plain",
".spc": "application/x-pkcs7-certificates",
".spp": "text/xml",
".sst": "application/vnd.ms-pki.certstore",
".stm": "text/html",
".svg": "text/xml",
".tdf": "application/x-tdf",
".tga": "application/x-tga",
".tif": "application/x-tif",
".tld": "text/xml",
".torrent": "application/x-bittorrent",
".txt": "text/plain",
".uls": "text/iuls",
".vda": "application/x-vda",
".vml": "text/xml",
".vsd": "application/vnd.visio",
".vss": "application/vnd.visio",
".vst": "application/x-vst",
".vsx": "application/vnd.visio",
".vxml": "text/xml",
".wax": "audio/x-ms-wax",
".wb2": "application/x-wb2",
".wbmp": "image/vnd.wap.wbmp",
".wk3": "application/x-wk3",
".wkq": "application/x-wkq",
".wm": "video/x-ms-wm",
".wmd": "application/x-ms-wmd",
".wml": "text/vnd.wap.wml",
".wmx": "video/x-ms-wmx",
".wp6": "application/x-wp6",
".wpg": "application/x-wpg",
".wq1": "application/x-wq1",
".wri": "application/x-wri",
".ws": "application/x-ws",
".wsc": "text/scriptlet",
".wvx": "video/x-ms-wvx",
".xdr": "text/xml",
".xfdf": "application/vnd.adobe.xfdf",
".xls": "application/vnd.ms-excel",
".xlw": "application/x-xlw",
".xpl": "audio/scpls",
".xql": "text/xml",
".xsd": "text/xml",
".xslt": "text/xml",
".x_b": "application/x-x_b",
".sisx": "application/vnd.symbian.install",
".ipa": "application/vnd.iphone",
".xap": "application/x-silverlight-app",
".zip": "application/x-zip-compressed",
}

17
src/index.html

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>uav-edu-mp</title>
<script><%= htmlWebpackPlugin.options.script % ></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

6
src/pages/evaluation/index.config.js

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '自我讲评',
disableSwipeBack: true,
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
})

621
src/pages/evaluation/index.vue

@ -0,0 +1,621 @@
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { useEvaluationStore, useAuthStore } from '../../stores';
import { storeToRefs } from 'pinia';
import Taro from '@tarojs/taro';
import { formatTime } from '../../utils/helpers';
import { Uploader } from '@nutui/icons-vue-taro';
const { getEvaluationList, getEvaluationDetail, replyEvaluation, updateEvaluation, createEvaluation } = useEvaluationStore();
const { evaluationList, evaluationExtra, evaluationQueries } = storeToRefs(useEvaluationStore());
const { isTeacher, isStudent } = storeToRefs(useAuthStore());
const replyContent = ref('');
const isReplying = ref(false);
const editContent = ref('');
const isEditing = ref(false);
const showEdit = ref(false);
const showCreate = ref(false);
const createContent = ref('');
const isCreating = ref(false);
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
const loading = ref(false);
const finished = ref(false);
const showDetail = ref(false);
const showReply = ref(false);
const currentDetail = ref(null);
const currentEvaluation = ref(null);
const isRefreshing = ref(false);
function onRefresh() {
isRefreshing.value = true;
evaluationQueries.value.pageNum = 1;
getEvaluationList().then(() => {
Taro.stopPullDownRefresh();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
isRefreshing.value = false;
});
}
function onLoadMore() {
if (loading.value) return;
if (evaluationList.value.length >= evaluationExtra.value.total) return;
loading.value = true;
evaluationQueries.value.pageNum += 1;
getEvaluationList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
}
function viewDetail(id) {
getEvaluationDetail(id).then(({ data }) => {
currentDetail.value = data;
showDetail.value = true;
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
}
function showReplyDialog(item) {
currentEvaluation.value = item;
replyContent.value = '';
isReplying.value = false;
showReply.value = true;
}
function submitReply() {
if (!replyContent.value.trim()) {
openToast('warn', '请输入教评内容');
return;
}
isReplying.value = true;
replyEvaluation(currentEvaluation.value.id, replyContent.value)
.then(() => {
openToast('success', '教评成功');
replyContent.value = '';
showReply.value = false;
onRefresh();
})
.catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
.finally(() => {
isReplying.value = false;
});
}
function showEditDialog(item) {
currentEvaluation.value = item;
editContent.value = item.content;
isEditing.value = false;
showEdit.value = true;
}
function submitEdit() {
if (!editContent.value.trim()) {
openToast('warn', '请输入评论内容');
return;
}
isEditing.value = true;
updateEvaluation({ id: currentEvaluation.value.id, content: editContent.value })
.then(() => {
openToast('success', '修改成功');
editContent.value = '';
showEdit.value = false;
onRefresh();
})
.catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
.finally(() => {
isEditing.value = false;
});
}
function submitCreate() {
if (!createContent.value.trim()) {
openToast('warn', '请输入自我讲评内容');
return;
}
isCreating.value = true;
createEvaluation({ content: createContent.value })
.then(() => {
openToast('success', '创建成功');
createContent.value = '';
showCreate.value = false;
onRefresh();
})
.catch(({ msg }) => {
if (msg) openToast('warn', msg);
})
.finally(() => {
isCreating.value = false;
});
}
function formatDate(date) {
return formatTime(date, 'YYYY-MM-DD');
}
onMounted(() => {
getEvaluationList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
});
Taro.usePullDownRefresh(() => {
onRefresh();
});
Taro.useReachBottom(() => {
onLoadMore();
});
</script>
<template>
<div :class="s.root">
<div class="list">
<div v-for="item in evaluationList" :key="item.id" class="item" :class="{ 'has-reply': item.hasReply }">
<div class="header">
<div class="student-name">{{ item.studentName || '匿名' }}</div>
<div class="status" v-if="!item.hasReply" :class="{ 'has-reply': item.hasReply }">{{ item.hasReply ? '已回复' : '未回复' }}</div>
</div>
<div class="content">{{ item.content }}</div>
<div class="footer">
<div class="date">{{ formatTime(item.createTime) }}</div>
<div class="buttons">
<nut-button size="mini" type="info" @click="viewDetail(item.id)">
<span>查看回复</span>
</nut-button>
<nut-button v-if="isStudent" size="mini" type="primary" @click="showEditDialog(item)">
<span>修改</span>
</nut-button>
<nut-button v-if="isTeacher" size="mini" type="primary" @click="showReplyDialog(item)">
<span>写教评</span>
</nut-button>
</div>
</div>
</div>
<nut-empty v-if="evaluationList.length === 0" description="暂无数据" />
<nut-infiniteloading
v-if="evaluationList.length"
load-txt="加载中..."
load-more-txt="没有更多了"
:has-more="evaluationList.length < evaluationExtra.total"
@load-more="loadData"
/>
</div>
<div class="create-button" v-if="isStudent" @click="showCreate = true">
<nut-button type="info"><Uploader /></nut-button>
</div>
<nut-popup v-model:visible="showDetail" position="bottom" :style="{ height: '70%' }" round>
<div class="detail" v-if="currentDetail">
<div class="main-content">
<div class="content">{{ currentDetail.content }}</div>
<div class="footer">
<div class="student-name">{{ currentDetail.studentName }}</div>
<div class="time">{{ formatTime(currentDetail.createTime) }}</div>
</div>
</div>
<div class="replies">
<div class="reply-title">回复列表</div>
<div v-if="currentDetail.replies && currentDetail.replies.length > 0" class="reply-list">
<div v-for="reply in currentDetail.replies" :key="reply.createTime" class="reply-item">
<div class="reply-content">{{ reply.content }}</div>
<div class="reply-info">
<span class="teacher">{{ reply.teacherName || '老师' }}</span>
<span class="time">{{ formatTime(reply.createTime) }}</span>
</div>
</div>
</div>
<nut-empty v-else description="暂无回复" />
</div>
</div>
</nut-popup>
<nut-popup v-model:visible="showReply" position="bottom" :style="{ height: '40%' }" round>
<div class="reply-popup">
<div class="reply-popup-header">
<div class="title">教评</div>
<div class="close" @click="showReply = false">
<i class="iconfont icon-close"></i>
</div>
</div>
<div class="reply-popup-content">
<nut-textarea
v-model="replyContent"
placeholder="请输入回复内容"
:maxlength="100"
:rows="4"
/>
<nut-button
block
type="primary"
:loading="isReplying"
@click="submitReply"
>提交回复</nut-button>
</div>
</div>
</nut-popup>
<nut-popup v-model:visible="showEdit" position="bottom" :style="{ height: '40%' }" round>
<div class="reply-popup">
<div class="reply-popup-header">
<div class="title">修改评论</div>
<div class="close" @click="showEdit = false">
<i class="iconfont icon-close"></i>
</div>
</div>
<div class="reply-popup-content">
<nut-textarea
v-model="editContent"
placeholder="请输入评论内容"
:maxlength="100"
:rows="4"
/>
<nut-button
block
type="primary"
:loading="isEditing"
@click="submitEdit"
>提交修改</nut-button>
</div>
</div>
</nut-popup>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :center="state.center" />
<nut-popup v-model:visible="showCreate" position="bottom" :style="{ height: '40%' }" round>
<div class="reply-popup">
<div class="reply-popup-header">
<div class="title">新建自我讲评</div>
<div class="close" @click="showCreate = false">
<i class="iconfont icon-close"></i>
</div>
</div>
<div class="reply-popup-content">
<nut-textarea
v-model="createContent"
placeholder="请输入自我讲评内容"
:maxlength="100"
:rows="4"
/>
<nut-button
block
type="primary"
:loading="isCreating"
@click="submitCreate"
>提交</nut-button>
</div>
</div>
</nut-popup>
</div>
</template>
<style lang="less" module="s">
.root {
box-sizing: border-box;
min-height: 100vh;
background-color: #f5f5f5;
padding: 16px;
:global {
.list {
.item {
background-color: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
position: relative;
overflow: visible;
z-index: 0;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background-color: #838383;
border-radius: 16px 0 0 16px;
z-index: 2;
transition: all 0.3s ease;
}
&.has-reply::before {
background-color: #00c691;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.student-name {
font-size: 16px;
color: #262626;
font-weight: 500;
}
.status {
font-size: 14px;
color: #8c8c8c;
padding: 4px 12px;
border-radius: 20px;
background-color: #f5f5f5;
&.has-reply {
color: #00c691;
background-color: rgba(0, 198, 145, 0.1);
}
}
}
.content {
font-size: 24px;
line-height: 1.5;
color: #262626;
font-weight: 500;
margin-bottom: 16px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
.date {
font-size: 14px;
color: #8c8c8c;
}
.buttons {
display: flex;
gap: 12px;
.nut-button {
height: 36px;
// padding: 0 16px;
border-radius: 20px;
// color: #00c691;
// border-color: #00c691;
// background-color: rgba(0, 198, 145, 0.1);
// &:hover {
// background-color: rgba(0, 198, 145, 0.2);
// }
// i {
// margin-right: 6px;
font-size: 16px;
// }
}
}
}
}
}
.loading, .no-more {
text-align: center;
padding: 16px;
color: #999;
font-size: 14px;
}
.detail {
pointer-events: none;
padding: 24px;
// height: 100%;
overflow-y: auto;
.main-content {
background-color: #f8f8f8;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
.content {
font-size: 24px;
line-height: 1.5;
color: #262626;
font-weight: 500;
margin-bottom: 16px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
.student-name {
font-size: 16px;
color: #262626;
font-weight: 500;
}
.time {
font-size: 14px;
color: #8c8c8c;
}
}
}
.replies {
.reply-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
color: #262626;
padding-left: 12px;
border-left: 4px solid #00c691;
}
.reply-list {
.reply-item {
background-color: #f8f8f8;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.3s ease;
&:hover {
background-color: #f0f0f0;
}
.reply-content {
font-size: 16px;
line-height: 1.6;
color: #262626;
margin-bottom: 12px;
}
.reply-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
.teacher {
color: #00c691;
font-weight: 500;
}
.time {
color: #8c8c8c;
}
}
}
}
}
}
.reply-popup {
padding: 20px;
// height: 100%;
display: flex;
flex-direction: column;
.reply-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
// font-size: 18px;
font-weight: bold;
color: #262626;
}
.close {
padding: 8px;
cursor: pointer;
color: #8c8c8c;
transition: all 0.3s ease;
&:hover {
color: #262626;
}
i {
font-size: 20px;
}
}
}
.reply-popup-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 20px 20px;
height: calc(100% - 60px);
overflow-y: auto;
.nut-textarea {
margin-bottom: 20px;
background-color: #f8f8f8;
border-radius: 12px;
padding: 16px;
font-size: 16px;
min-height: 120px;
}
.nut-button {
// height: 48px;
border-radius: 12px;
// font-size: 16px;
// background-color: #00c691;
// border-color: #00c691;
margin-top: auto;
// &:active {
// background-color: darken(#00c691, 10%);
// border-color: darken(#00c691, 10%);
// }
}
}
}
.create-button {
position: fixed;
left: 24px;
bottom: 10vh;
// width: 56px;
// height: 56px;
border-radius: 50%;
// background-color: #00c691;
// box-shadow: 0 4px 12px rgba(0, 198, 145, 0.3);
// display: flex;
// align-items: center;
// justify-content: center;
// cursor: pointer;
// transition: all 0.3s ease;
// z-index: 100;
// &:hover {
// transform: translateY(-2px);
// // box-shadow: 0 6px 16px rgba(0, 198, 145, 0.4);
// }
.nut-button {
border-radius: 50%;
}
i {
width: 10px;
height: 24px;
// font-size: 24px;
color: #fff;
}
}
}
}
</style>

6
src/pages/flight/index.config.js

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '实践飞行',
disableSwipeBack: true,
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
})

226
src/pages/flight/index.vue

@ -0,0 +1,226 @@
<script setup>
import { reactive, ref } from 'vue';
import Taro from "@tarojs/taro";
import { storeToRefs } from "pinia";
import { useFlightStore } from "../../stores";
const { getFlightList } = useFlightStore();
const { flightList, flightExtra, flightQueries } = storeToRefs(useFlightStore());
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
const loading = ref(false);
const isRefreshing = ref(false);
function onRefresh() {
isRefreshing.value = true;
flightQueries.value.pageNum = 1;
getFlightList().then(() => {
Taro.stopPullDownRefresh();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
isRefreshing.value = false;
});
}
function onLoadMore() {
if (loading.value) return;
if (flightList.value.length >= flightExtra.value.total) return;
loading.value = true;
flightQueries.value.pageNum += 1;
getFlightList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
}
function onNavTo(id) {
Taro.navigateTo({
url: `/pages/flightMap/index?id=${id}`,
});
}
Taro.usePullDownRefresh(() => {
onRefresh();
});
Taro.useReachBottom(() => {
onLoadMore();
});
Taro.useDidShow(() => {
getFlightList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
});
</script>
<template>
<div :class="s.root">
<div class="list">
<div class="item" v-for="item in flightList" :key="item.id" :class="{ 'is-pass': item.isPass }">
<div class="title">
<span>{{ item.studentName || '-' }}</span>
<nut-button size="mini" type="info" @click="onNavTo(item.id)">回放</nut-button>
</div>
<div class="info">
<div class="info-row">
<span>班级{{ item.className || '-' }}</span>
<span>无人机{{ item.droneName || '-' }}</span>
</div>
<div class="info-row">
<span>教师{{ item.teacherName || '-' }}</span>
</div>
<div class="info-row">
<span>场地{{ item.airfieldName || '-' }}</span>
</div>
<div class="info-row">
<span>开始时间{{ item.startTime || '-' }}</span>
</div>
<div class="info-row">
<span><nut-tag :type="item.isPass ? 'success' : 'danger'">{{ item.isPass ? '通过' : '未通过' }}</nut-tag></span>
<span>失败次数{{ item.failTimes || 0 }}</span>
</div>
</div>
</div>
<nut-empty v-if="!loading && !flightList.length" description="暂无数据" />
<nut-infiniteloading
v-if="flightList.length"
load-txt="加载中..."
load-more-txt="没有更多了"
:has-more="flightList.length < flightExtra.total"
@load-more="onLoadMore"
/>
</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">
page {
height: 100%;
padding: 20px 0;
box-sizing: border-box;
background-color: #eaeaea;
overflow: hidden;
}
.root {
height: 100%;
display: flex;
flex-direction: column;
:global {
.list {
flex: 1;
overflow: auto;
padding: 0 20px;
.item {
background-color: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 100%;
background-color: #4CAF50;
opacity: 0.8;
}
&:not(.is-pass)::before {
background-color: #FF5252;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
&:active {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
& + .item {
margin-top: 28px;
}
.title {
font-size: 38px;
font-weight: 600;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
color: #2c3e50;
:global(.nut-button) {
font-size: 28px;
padding: 10px 24px;
border-radius: 8px;
background: rgba(74, 144, 226, 0.12);
color: #4a90e2;
border: none;
font-weight: 500;
transition: all 0.2s ease;
&:active {
transform: scale(0.96);
opacity: 0.9;
}
}
}
.info {
color: #666;
font-size: 28px;
line-height: 1.6;
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
&:last-child {
margin-bottom: 0;
}
span {
color: #34495e;
&:first-child {
color: #7f8c8d;
}
}
}
}
}
}
}
}
</style>

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

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

252
src/pages/flightMap/index.vue

@ -0,0 +1,252 @@
<script setup>
import { onMounted, ref, watch, onUnmounted, computed } from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import { creatEightShaped, convertToTrajectory } from './utils';
import trackData from './track.json';
import deviceCruise from '../../core/deviceCruise';
import TrackPlayback from '../../components/TrackPlayback.vue';
import RealTimeData from '../../components/RealTimeData.vue';
// Taro.hideHomeButton();
//
const centerPoint = ref({
lat: 39.908692,
lng: 116.397477
});
//
let mapContext = null;
const trajectory = convertToTrajectory(trackData, {
startTime: "2025-03-14T08:00:00Z",
interval: 1000 // 1
});
const show = ref(false);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = true;
console.log(mapContext);
initEightShaped();
initDeviceCruise();
});
//
let eightShaped = null;
//
const polygons = ref([]);
//
const circles = ref([]);
//
const markerPoints = ref([]);
//
const polyline = ref([{
points: trackData.map(coord => ({
latitude: coord[1],
longitude: coord[0]
})),
color: '#008000',
width: 4,
arrowLine: true
}]);
//
const bearing = ref(0);
//
function initEightShaped() {
if (!mapContext) return;
const center1 = [106.630154, 26.647661];
const center2 = [106.632154, 26.649661];
//
let angle = turf.bearing(center1, center2);
// 90使
// 0-360
angle = ((angle % 360) + 360) % 360;
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
center: center1,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, {
center: center2,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, [6, 0, 1, 3, 4, 5, 2], { radius: 50 });
//
polygons.value = shapePolygons;
markerPoints.value = shapeMarkers;
circles.value = [
...shapeCircles,
{
latitude: center1[1],
longitude: center1[0],
color: '#FF0000',
fillColor: '#FF0000',
radius: 5,
strokeWidth: 2
},
{
latitude: center2[1],
longitude: center2[0],
color: '#FF0000',
fillColor: '#FF0000',
radius: 5,
strokeWidth: 2
}
];
//
// 使
mapContext.includePoints({
points: [
{ latitude: center1[1], longitude: center1[0] },
{ latitude: center2[1], longitude: center2[0] }
],
padding: [50, 50, 110, 50],
success: (res) => {
setTimeout(() => {
// 使
bearing.value = ((angle + 5) % 360 + 360) % 360;
}, 500);
// bearing.value = angle.toFixed(2) - 85;
console.log('地图视野设置成功', res);
},
fail: (err) => {
console.error('地图视野设置失败', err);
}
});
}
//
// watch(() => centerPoint.value, () => {
// if (!mapContext || !eightShaped) return;
// //
// initEightShaped();
// }, { deep: true });
//
function initDeviceCruise() {
console.log(mapContext);
deviceCruise.setMap(mapContext);
deviceCruise.loadTrack(trajectory);
// markerPoints.value.push(deviceCruise._marker.value);
// console.log(markerPoints.value);
}
//
// const allMarkers = computed(() => {
// // console.log(markerPoints.value, deviceCruise._marker.value);
// // eslint-disable-next-line vue/no-side-effects-in-computed-properties
// // markerPoints.value.push(deviceCruise._marker.value);
//
// // return [...markerPoints.value, deviceCruise._marker];
// return markerPoints;
// });
// Taro.getLaunchOptionsSync().then(res => {
// console.log(res);
// });
//
onUnmounted(() => {
if (deviceCruise) {
deviceCruise.destroy();
}
});
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:polygons="polygons"
:circles="circles"
:markers="markerPoints"
:polyline="polyline"
:enable-satellite="true"
:enable-rotate="true"
:rotate="bearing"
/>
</view>
<TrackPlayback />
<RealTimeData class="real-time-data" />
</view>
</template>
<style lang="less" module="s">
page {
height: 100%;
overflow: hidden;
}
.root {
position: relative;
width: 100%;
height: 100%;
padding: 0 !important;
overflow: hidden;
// position: fixed;
// top: 0;
// left: 0;
:global {
.mapBox {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
#map {
width: 100%;
height: 100%;
// z-index: -1;
}
.real-time-data {
//position: absolute;
//left: 20px;
//top: 20px;
//z-index: 2;
}
.auth-tip {
position: absolute;
left: 40px;
right: 40px;
top: 40px;
background-color: fade(black, 50%);
backdrop-filter: blur(6px);
padding: 40px;
border-radius: 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 28px;
color: yellow;
gap: 20px;
}
}
}
</style>

1446
src/pages/flightMap/track.json

File diff suppressed because it is too large

358
src/pages/flightMap/utils.js

@ -0,0 +1,358 @@
import * as turf from '@turf/turf';
import deviceIcon from '../../assets/deviceIcon.png';
// 创建同心圆多边形点集
function _createCircle({ center, redRadius, radiusDiff }) {
const outerRadius = redRadius + radiusDiff; // 外圆半径
const innerRadius = redRadius - radiusDiff; // 内圆半径
// 配置圆形生成选项
const options = {steps: 64, units: 'meters'}; // 增加steps以使圆形更平滑
// 生成外圆和内圆
const outerCircle = turf.circle([center.lng, center.lat], outerRadius, options);
const innerCircle = turf.circle([center.lng, center.lat], innerRadius, options);
// 生成红色参考圆
const redCircle = turf.circle([center.lng, center.lat], redRadius, options);
// 创建环形(外圆 - 内圆)
const featureCollection = turf.featureCollection([outerCircle, innerCircle]);
const ring = turf.difference(featureCollection);
// 转换坐标格式为小程序地图组件要求的格式
const convertCoordinates = (coords) => {
return coords.map(coord => ({
longitude: coord[0],
latitude: coord[1]
}));
};
// // 获取环形的坐标点集
// const ringCoords = ring.geometry.coordinates[0];
// 获取红色圆的坐标点集
const redCircleCoords = redCircle.geometry.coordinates[0];
// 返回所需的点集
return {
ring: [...convertCoordinates(ring.geometry.coordinates[0]), ...convertCoordinates(ring.geometry.coordinates[1])],
redCircle: convertCoordinates(redCircleCoords),
center: {
latitude: center.lat,
longitude: center.lng
}
};
}
// 添加标记点
export function addCircleMarkers(circle1, circle2, indexMap = [6, 0, 1, 3, 4, 5, 2], { radius = 5 }) {
const center1 = turf.point(circle1.center);
const center2 = turf.point(circle2.center);
// 1. 计算两圆心连线方位角
const bearing = turf.bearing(center1, center2);
// 2. 生成基准交点
const basePoint1 = turf.destination(center1, circle1.radius, bearing, { units: 'meters' });
const basePoint2 = turf.destination(center2, circle2.radius, bearing + 180, { units: 'meters' });
// 3. 生成旋转点集合
const generateRotatedPoints = (center, baseCoord) => {
const points = [];
for(let angle = 0; angle < 360; angle += 90) {
const rotatedCoord = turf.transformRotate(
turf.point(baseCoord),
angle,
{pivot: center}
).geometry.coordinates;
points.push(rotatedCoord);
}
return points.slice(1); // 排除原始点
};
// 4. 生成所有标记点
const positions = [
...generateRotatedPoints(circle1.center, basePoint1.geometry.coordinates),
...generateRotatedPoints(circle2.center, basePoint2.geometry.coordinates),
turf.midpoint(center1, center2).geometry.coordinates // 连线中点
];
const circles = [];
const markers = [];
// 5. 生成标记点数据
positions.forEach((coord, index) => {
const markerPoint = {
longitude: coord[0],
latitude: coord[1]
};
// 为中点位置添加圆形背景
if (index === 6) {
circles.push({
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
// fillColor: '#00000000',
color: '#FF0000',
radius,
strokeWidth: 2
});
}
// 添加数字标记
markers.push({
id: index,
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
iconPath: deviceIcon,
width: 10,
height: 10,
label: {
content: String(indexMap[index] + 1),
color: '#FFFFFF',
fontSize: 20,
textStrokeWidth: 2,
textStrokeColor: '#000000',
anchorX: -6,
anchorY: -12,
bgColor: '#00000000'
}
});
});
return { circles, markers };
}
export function creatEightShaped(circleOptions1 = {}, circleOptions2 = {}, indexMap = [6, 0, 1, 3, 4, 5, 2], markerOptions = { radius: 50 }) {
const circle1 = _createCircle({
center: { lng: circleOptions1.center[0], lat: circleOptions1.center[1] },
redRadius: circleOptions1.radius,
radiusDiff: circleOptions1.radiusDiff
});
const circle2 = _createCircle({
center: { lng: circleOptions2.center[0], lat: circleOptions2.center[1] },
redRadius: circleOptions2.radius,
radiusDiff: circleOptions2.radiusDiff
});
const polygons = [];
// 添加第一个圆的环形
polygons.push({
points: circle1.ring,
fillColor: '#FFFFFF80',
strokeColor: '#00000000',
strokeWidth: 2
});
polygons.push({
points: circle1.redCircle,
fillColor: '#00000000',
strokeColor: '#FF0000',
strokeWidth: 2
});
// 添加第二个圆的环形
polygons.push({
points: circle2.ring,
fillColor: '#FFFFFF80',
strokeColor: '#00000000',
strokeWidth: 2
});
polygons.push({
points: circle2.redCircle,
fillColor: '#00000000',
strokeColor: '#FF0000',
strokeWidth: 2
});
// 生成标记点和圆形标记
const center1 = turf.point(circleOptions1.center);
const center2 = turf.point(circleOptions2.center);
// 计算两圆心连线方位角
const bearing = turf.bearing(center1, center2);
// 生成基准交点
const basePoint1 = turf.destination(center1, circleOptions1.radius, bearing, { units: 'meters' });
const basePoint2 = turf.destination(center2, circleOptions2.radius, bearing + 180, { units: 'meters' });
// 生成旋转点集合
const generateRotatedPoints = (center, baseCoord) => {
const points = [];
for(let angle = 0; angle < 360; angle += 90) {
const rotatedCoord = turf.transformRotate(
turf.point(baseCoord),
angle,
{pivot: center}
).geometry.coordinates;
points.push(rotatedCoord);
}
return points.slice(1); // 排除原始点
};
// 生成所有标记点
const positions = [
...generateRotatedPoints(circleOptions1.center, basePoint1.geometry.coordinates),
...generateRotatedPoints(circleOptions2.center, basePoint2.geometry.coordinates),
turf.midpoint(center1, center2).geometry.coordinates // 连线中点
];
const circles = [];
const markers = [];
// 生成标记点数据
positions.forEach((coord, index) => {
const markerPoint = {
longitude: coord[0],
latitude: coord[1]
};
// 为中点位置添加圆形背景
if (index === 6) {
circles.push({
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
color: '#FF0000',
radius: markerOptions.radius,
strokeWidth: 2
});
}
// 添加数字标记
markers.push({
id: index,
latitude: markerPoint.latitude,
longitude: markerPoint.longitude,
iconPath: deviceIcon,
width: 1,
height: 1,
label: {
content: String(indexMap[index] + 1),
color: '#FFFFFF',
fontSize: 20,
textStrokeWidth: 2,
textStrokeColor: '#000000',
anchorX: -6,
anchorY: -12,
bgColor: '#00000000'
}
});
});
return { polygons, circles, markers };
}
// function fitView(mapContent) {
// if (!this._circle1 || !this._circle2) return;
//
// const points = [
// ...this._circle1.ring,
// ...this._circle2.ring
// ];
//
// // 计算所有点的边界
// const lngs = points.map(p => p.longitude);
// const lats = points.map(p => p.latitude);
// const minLng = Math.min(...lngs);
// const maxLng = Math.max(...lngs);
// const minLat = Math.min(...lats);
// const maxLat = Math.max(...lats);
//
// // 设置地图视野
// this._map.includePoints({
// points: [
// { latitude: minLat, longitude: minLng },
// { latitude: maxLat, longitude: maxLng }
// ],
// padding: [50]
// });
// }
// 创建八字飞行图形
// export function EightShapedFlight() {
// // 创建八字形
//
// // 调整视野以适应两个圆形
//
//
// // 销毁实例
// destroy() {
// this._circle1 = null;
// this._circle2 = null;
// }
// }
///////////////
/**
* 将二维坐标数组转换为轨迹对象包含自动生成的时间戳和航向角
* @param {Array} coords 二维数组格式如 [[lng, lat], ...]
* @param {Object} [options] 配置选项
* @param {Date|string} [options.startTime] 起始时间(默认当前时间)
* @param {number} [options.interval=1000] 时间间隔(毫秒)
* @param {boolean} [options.calculateYaw=true] 是否计算航向角
* @returns {Object} 轨迹对象 {id: string, points: Array}
*/
export function convertToTrajectory(coords, options = {}) {
// 参数处理
const {
startTime = new Date(),
interval = 1000,
calculateYaw = true
} = options;
const startTimestamp = new Date(startTime).getTime();
const id = `trajectory_${startTimestamp}`;
// 核心转换逻辑
const points = coords.map(([lng, lat], index, arr) => {
// 生成时间戳
const timestamp = startTimestamp + index * interval;
// 航向角计算
let yaw = 0;
if (calculateYaw && index > 0) {
const prev = arr[index - 1];
yaw = calculateBearing(prev, [lng, lat]);
}
return {
lng,
lat,
timestamp,
yaw: parseFloat(yaw.toFixed(2)),
alt: index * 2 // 示例高度值
};
});
// 闭环处理(首尾坐标相同时)
// if (calculateYaw && points.length > 1) {
// const first = points[0];
// const last = points[points.length - 1];
// if (first.lng === last.lng && first.lat === last.lat) {
// last.yaw = calculateBearing(points[points.length - 2], first);
// }
// }
return { id, points };
}
// 航向角计算函数(单位:度)
export function calculateBearing(prev, current) {
// console.log(prev, current);
const [lng1, lat1] = prev;
const [lng2, lat2] = current;
const dLon = (lng2 - lng1) * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2 * Math.PI / 180);
const x = Math.cos(lat1 * Math.PI / 180) * Math.sin(lat2 * Math.PI / 180) -
Math.sin(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.cos(dLon);
let angle = Math.atan2(y, x);
return ((angle * 180 / Math.PI) + 360) % 360; // 转换为0-360度
}

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

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '享飞低空培训',
enablePullDownRefresh: false,
disableScroll: false,
})

350
src/pages/home/index.vue

@ -0,0 +1,350 @@
<script setup>
// import { ref } from 'vue';
import Taro from "@tarojs/taro";
import TabBar from '../../components/TabBar.vue';
// import { useTaskStore } from "../../stores";
// import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { RectRight } from '@nutui/icons-vue-taro'
import list from '../../assets/list.png';
import flower from '../../assets/flower.png';
Taro.hideHomeButton();
// const { getTaskList } = useTaskStore();
// const { taskList } = storeToRefs(useTaskStore());
// const taskProgressList = ref([])
// const { params } = Taro.useRouter();
// if (params?.navToRecord) {
// // setTimeout(() => {
// onNavTo();
// // }, 3000)
// }
// onUnmounted(() => {
// // taskList.value = [];
// });
function onNavToFlight() {
Taro.navigateTo({
url: '/pages/flight/index',
})
}
function onNavToRoutePlan() {
Taro.navigateTo({
url: '/pages/routePlan/index',
})
}
function onNavToTrip() {
Taro.navigateTo({
url: '/pages/returnTrip/index',
})
}
function onNavToEvaluation() {
Taro.navigateTo({
url: '/pages/evaluation/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;
}
</script>
<template>
<div :class="s.root">
<div class="container" >
<div class="item record-box-1" @click="onNavToFlight">
<div class="nav-box">
<div class="text">实践飞行</div>
<div class="right">
<RectRight />
</div>
</div>
<image :src="list" class="image" />
</div>
<div class="item record-box-2" @click="onNavToRoutePlan">
<div class="nav-box">
<div class="text">航线规划</div>
<div class="right">
<RectRight />
</div>
</div>
<image :src="list" class="image" />
</div>
<div class="item record-box-3" @click="onNavToTrip">
<div class="nav-box">
<div class="text">应急返航</div>
<div class="right">
<RectRight />
</div>
</div>
<image :src="list" class="image" />
</div>
<div class="item record-box-4" @click="onNavToEvaluation">
<div class="nav-box">
<div class="text">自我讲评</div>
<div class="right">
<RectRight />
</div>
</div>
<image :src="list" class="image" />
</div>
</div>
<div class="tip-content">
<div class="tip-center">
<div class="round">
<div class="round2"></div>
</div>
<div class="content">
<div class="text">本程序可查看学习过程实操记录</div>
<div class="text">每天给自己一个评价进步更快哦 </div>
</div>
<div class="tmp"></div>
</div>
<image :src="flower" class="flower" />
</div>
<div class="tip-center" v-if="false">
<div style="display: flex;">
<div>1</div><div style="flex: 1">本程序可查看学习过程实操记录</div>
</div>
<div style="display: flex;margin-top: 10px;">
<div>2</div><div style="flex: 1">每天给自己一个评价进步更快哦 </div>
</div>
</div>
<div class="bottom-text">
低空培训系统技术支持
</div>
</div>
<TabBar />
<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%;
padding: 0 40px;
box-sizing: border-box;
overflow: hidden;
background-color: #f2fbff;
}
.root {
height: 100%;
:global {
.container {
margin-top: 80px;
//margin-bottom: 40px;
//text-align: center;
//font-weight: bold;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40px;
//align-items: center;
//justify-content: center;
.item {
flex: 1;
box-sizing: border-box;
//object-fit: cover;
//width: 300px;
//height: 360px;
height: 300px;
//border: 6px solid #000;
border-radius: 20px;
//display: flex;
//align-items: center;
//justify-content: center;
position: relative;
font-size: 40px;
color: white;
padding: 40px;
overflow: hidden;
//&:active {
// background-color: rgba(204, 204, 204, 0.4);
//}
&.record-box-1 {
box-shadow: 5px 5px 10px 5px rgba(0, 140, 112, 0.3);
background: linear-gradient(218.31deg, rgb(0, 195, 156) 3.576%,rgb(0, 140, 112) 94.437%);
.nut-icon {
color: rgb(0, 195, 156);
font-size: 20px;
//width: 10px;
//height: 10px;
}
}
&.record-box-2 {
box-shadow: 5px 5px 10px 5px rgba(0, 89, 195, 0.3);
background: linear-gradient(218.31deg, rgb(66, 152, 255) 3.576%,rgb(0, 89, 195) 94.437%);
.nut-icon {
color: rgb(66, 152, 255);
font-size: 20px;
}
}
&.record-box-3 {
box-shadow: 5px 5px 10px 5px rgba(195, 107, 0, 0.3);
background: linear-gradient(218.31deg, rgb(255, 167, 66) 3.576%, rgb(195, 117, 0) 94.437%);
.nut-icon {
color: rgb(255, 173, 66);
font-size: 20px;
}
}
&.record-box-4 {
box-shadow: 5px 5px 10px 5px rgba(0, 195, 159, 0.3);
background: linear-gradient(218.31deg, rgb(66, 233, 255) 3.576%, rgb(0, 189, 195) 94.437%);
.nut-icon {
color: rgb(66, 227, 255);
font-size: 20px;
}
}
.nav-box {
display: flex;
align-items: center;
justify-content: space-between;
.right {
width: 40px;
height: 40px;
background-color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
.image {
position: absolute;
bottom: -20px;
right: -20px;
width: 190px;
height: 180px;
object-fit: cover;
//filter: grayscale(50%);
opacity: 0.5;
}
}
}
.tab-content {
padding: 40px 10px;
.at-input {
margin-left: 0;
}
}
.btn {
margin-top: 60px;
}
.tip-content {
margin-top: 80px;
position: relative;
.tip-center {
overflow: hidden;
//position: absolute;
//left: 0;
//right: 0;
//bottom: 740px;
//text-align: center;
position: relative;
border-radius: 20px;
box-shadow: 4px 4px 12px 0px rgba(255, 237, 189, 0.4);
background: linear-gradient(180.00deg, rgb(255, 242, 206),rgb(255, 235, 181) 100%);
color: rgb(204, 119, 0);
padding: 40px;
display: flex;
align-items: center;
//justify-content: ;
//padding: 0 40px;
.round {
position: absolute;
top: -100px;
left: -100px;
overflow: hidden;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgb(252, 228, 166);
z-index: 0;
}
.content {
z-index: 1;
.text + .text {
margin-top: 30px;
}
}
.tmp {
width: 286px;
//height: 300px;
}
}
.flower {
position: absolute;
bottom: -20px;
right: -20px;
width: 286px;
height: 320px;
}
}
.bottom-text {
position: absolute;
left: 0;
right: 0;
bottom: 240px;
text-align: center;
font-size: 28px;
//color: #058a00;
//font-weight: bold;
}
}
}
</style>

7
src/pages/login/index.config.js

@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '登录',
// navigationStyle: "custom",
// navigationBarTextStyle:'white',
enablePullDownRefresh: false,
disableScroll: false,
})

279
src/pages/login/index.vue

@ -0,0 +1,279 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import Taro from "@tarojs/taro";
import { useAuthStore } from '../../stores';
import * as auth from '../../utils/auth';
import { Loading } from '@nutui/icons-vue-taro'
import { storeToRefs } from 'pinia';
const { loginWithPassword, wechatAuth, updateStudentAvatar, updateMemberAvatar } = useAuthStore();
const { userInfo, isStudent } = storeToRefs(useAuthStore());
const showLoginDialog = ref(false);
const pageLoading = ref(false);
// const loggedInLoading = ref(false);
const aUrl = ref('');
function updataAvatar() {
if (!userInfo.value?.id) return;
if (!aUrl.value) return;
if (isStudent.value) {
updateStudentAvatar({ avatar: aUrl.value, id: userInfo.value?.id }).then(() => {
userInfo.value.avatar = aUrl.value;
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
} else {
updateMemberAvatar({ avatar: aUrl.value, id: userInfo.value?.id }).then(() => {
userInfo.value.avatar = aUrl.value;
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
}
}
function wxCodeLogin(loadRes = {}) {
Taro.login({
success: (loginRes) => {
wechatAuth({ code: loginRes.code, sceneId: loadRes.scene }).then(() => {
if (auth.getToken()) {
updataAvatar();
showLoginDialog.value = false;
Taro.reLaunch({ url: '/pages/home/index' });
// openToast('success', ``);
return;
}
if (loadRes.scene && (`${loadRes.scene}`.length > 6)) {
showLoginDialog.value = true;
return;
}
if (!loadRes.scene && showLoginDialog.value) {
openToast('warn', '未查询到用户,请先在地面站绑定');
// showLoginDialog.value = true;
return;
}
console.log('userInfo', userInfo.value);
}).catch(({ msg }) => {
// console.log('eee', err);
if (msg) openToast('warn', msg);
}).finally(() => {
pageLoading.value = false;
});
},
fail: () => {
pageLoading.value = false;
openToast('warn', '未得到微信授权Code');
}
})
}
Taro.useLoad((loadRes) => {
pageLoading.value = true;
console.log('app load', loadRes);
wxCodeLogin(loadRes);
});
function onGetAvatar() {
Taro.getUserProfile({
desc: '用于完善用户资料',
success: (userProfile) => {
const { userInfo: { avatarUrl = '' } = {} } = userProfile || {};
console.log('avatarUrl', avatarUrl);
aUrl.value = avatarUrl;
},
})
}
function onLoggedIn() {
pageLoading.value = true;
wxCodeLogin();
onGetAvatar();
}
const loading = ref(false);
const formRef = ref();
const INIT_FORM_DATA = {
phone: '',
password: '',
};
const formData = ref({ ...INIT_FORM_DATA });
const submit = () => {
onGetAvatar();
formRef.value?.validate().then(({ valid }) => {
if (!valid) return;
loading.value = true;
loginWithPassword(formData.value).then(() => {
updataAvatar();
Taro.reLaunch({ url: '/pages/own/index' });
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
})
}
// Promise
const asyncValidator = (val) => {
const phoneReg = /^400(-?)[0-9]{7}$|^1\d{10}$|^0[0-9]{2,3}-[0-9]{7,8}$/;
if (!val) {
return Promise.reject('请输入手机号')
} else if (!phoneReg.test(val)) {
return Promise.reject('手机号格式不正确')
} else {
return Promise.resolve('');
}
}
const rules = ref({
phone: [
{ required: true, message: '请填写手机号' },
{ validator: asyncValidator, message: '电话格式不正确' }
],
password: [
{ required: true, message: '请填写密码' },
],
})
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">
<div class="title">低空培训系统</div>
<nut-form
ref="formRef"
label-position="top"
star-position="left"
:model-value="formData"
:rules="rules">
<nut-form-item label="手机号" prop="phone">
<nut-input v-model="formData.phone" placeholder="请输入手机号" type="number" />
</nut-form-item>
<nut-form-item label="密码" prop="password">
<nut-input v-model="formData.password" placeholder="请输入密码" type="password" />
</nut-form-item>
<nut-form-item>
<nut-button :loading="loading" type="info" size="large" @click="submit">登录</nut-button>
</nut-form-item>
</nut-form>
<div class="bottom-end">低空培训系统技术支持</div>
</div>
<nut-toast :msg="state.msg" v-model:visible="state.show" :type="state.type" :cover="state.cover" :duration="2000" />
<nut-dialog
title="提示"
v-model:visible="showLoginDialog"
:close-on-click-overlay="false">
<div style="text-align: center;font-size: 18px;color: black;">请先在地面站绑定</div>
<template #footer>
<nut-button type="info" @click="onLoggedIn" :loading="loggedInLoading">已绑定</nut-button>
</template>
</nut-dialog>
<nut-overlay v-model:visible="pageLoading" :overlay-class="s.overlay" :close-on-click-overlay="false">
<div class="loading-wrapper">
<Loading style="color: white;font-size: 34px;" />
</div>
</nut-overlay>
</template>
<style lang="less" module="s">
page {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
// padding: 20px;
// box-sizing: border-box;
background: linear-gradient(135deg, #1a237e 0%, #0d47a1 100%);
overflow: hidden;
}
.overlay {
:global {
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
}
}
.root {
height: 100%;
width: 100%;
overflow: hidden;
max-width: 90%;
// padding: 40px 20px;
position: relative;
display: flex;
// flex-direction: column;
align-items: center;
justify-content: center;
:global {
.title {
position: absolute;
top: 250px;
left: 0;
right: 0;
font-size: 48px;
text-align: center;
color: white;
font-weight: bold;
}
.nut-form {
// margin-top: 200px;
border-radius: 16px;
display: block;
width: 100%;
transform: translateY(-20%);
// margin-top: 200px;
.nut-cell-group__wrap {
box-shadow: none;
}
.nut-button {
// width: 100%;
border-radius: 16px;
// height: 50px;
// line-height: 50px;
// font-size: 18px;
// font-weight: bold;
}
}
.bottom-end {
position: absolute;
left: 0;
right: 0;
bottom: 60px;
text-align: center;
color: rgba(255, 255, 255, 0.8);
font-size: 24px;
}
}
}
</style>

3
src/pages/own/index.config.js

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的'
}

171
src/pages/own/index.vue

@ -0,0 +1,171 @@
<script setup>
import TabBar from "../../components/TabBar.vue";
import { ref } from "vue";
import Taro from "@tarojs/taro";
import { storeToRefs } from "pinia";
import { useAuthStore } from "../../stores";
import * as UserInfo from "../../utils/userInfo";
import * as Auth from "../../utils/auth";
import FormData from '../../core/FormData';
Taro.hideHomeButton();
const { userInfo, isStudent, isTeacher } = storeToRefs(useAuthStore());
const { updateStudentAvatar, updateMemberAvatar, uploadFile } = useAuthStore();
const isOpened = ref(false);
const avatarUrl = ref('');
const weightClasses = {
small: "小型",
medium: "中型",
large: "大型",
}
function handleConfirm() {
UserInfo.remove();
Auth.removeToken();
userInfo.value = {};
Taro.reLaunch({ url: '/pages/login/index' })
}
const loading = ref(false);
function onChooseAvatar(e) {
loading.value = true;
const { avatarUrl: url = '' } = e.detail || {};
// console.log('avatarUrl', e);
const form = new FormData();
form.appendFile('file', url);
form.append('type', 'avatar');
const fileData = form.getData();
uploadFile(fileData.buffer, { header: { 'content-type': `${fileData.contentType}` } }).then(({ data }) => {
avatarUrl.value = data.fileUrl;
form.clearCacheData();
if (isStudent.value) {
console.log('isS');
updateStudentAvatar({ avatar: avatarUrl.value, id: userInfo.value?.id }).then(() => {
userInfo.value.avatar = avatarUrl.value;
Taro.showToast({ title: '头像更新成功', icon: 'success' });
}).catch(({ msg }) => {
if (msg) Taro.showToast({ title: msg, icon: 'error' });
}).finally(() => {
loading.value = false;
});
} else {
console.log('isM');
updateMemberAvatar({ avatar: avatarUrl.value, id: userInfo.value?.id }).then(() => {
userInfo.value.avatar = avatarUrl.value;
// showAvatarDialog.value = false;
Taro.showToast({ title: '头像更新成功', icon: 'success' });
}).catch(({ msg }) => {
if (msg) Taro.showToast({ title: msg, icon: 'error' });
}).finally(() => {
loading.value = false;
});
}
}).catch(({ msg }) => {
if (msg) Taro.showToast({ title: msg, icon: 'error' });
});
// avatarUrl.value = url;
// if (!avatarUrl.value) {
// loading.value = false;
// return;
// }
}
function onCancel(e) {
console.log('aaa', e);
}
Taro.onError((e) => {
console.log('onError', e);
})
Taro.useError((e) => {
console.log('useError', e);
})
Taro.useDidShow((e) => {
console.log('useDidShow', e);
})
</script>
<template>
<div :class="s.root">
<div class="top">
<nut-avatar size="large"><img :src="userInfo?.avatar" /></nut-avatar>
<div class="name">{{ userInfo?.name || '-' }}</div>
</div>
<div v-if="isStudent">
<nut-cell title="班级" :desc="userInfo?.class?.name || '-'"></nut-cell>
<nut-cell title="考试等级" :desc="userInfo?.licenseGrade?.name || '-'"></nut-cell>
<nut-cell title="无人机重量级" :desc="weightClasses[userInfo?.weightClass] || '-'"></nut-cell>
</div>
<div v-else>
<nut-cell v-if="userInfo?.courses" title="教学内容" :desc="userInfo?.courses || '-'"></nut-cell>
<nut-cell v-if="userInfo?.class" title="负责班级" :desc="userInfo?.class?.name || '-'"></nut-cell>
<nut-cell v-if="userInfo?.organization" title="机构名称" :desc="userInfo?.organization?.name || '-'"></nut-cell>
</div>
<nut-button shape="square" class="btn" type="info" size="large" open-type="chooseAvatar" @chooseavatar="onChooseAvatar" :loading="loading" @error="onCancel">更换头像</nut-button>
<nut-button shape="square" type="primary" v-if="!isStudent && !isTeacher" class="btn" size="large" @click="isOpened = true">退出</nut-button>
</div>
<nut-dialog
title="退出提示"
content="确定退出当前账号吗?"
v-model:visible="isOpened"
@ok="handleConfirm"
/>
<TabBar />
</template>
<style lang="less" module="s">
page {
box-sizing: content-box;
overflow: hidden;
background-color: #f2fbff !important;
// background-color: #f2fbff;
height: 100%;
}
.root {
box-sizing: content-box;
background-color: white;
margin: 30px;
height: 100%;
padding: 30px;
border-radius: 30px;
:global {
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30px;
.name {
margin-right: 50px;
font-weight: bold;
}
}
//.at-list__item-extra {
// max-width: 380px;
//
// .item-extra__info {
// white-space: unset;
// }
//}
.btn {
margin-top: 40px;
border-radius: 16px;
}
}
}
</style>

6
src/pages/returnTrip/index.config.js

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '应急返航',
disableSwipeBack: true,
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
})

215
src/pages/returnTrip/index.vue

@ -0,0 +1,215 @@
<script setup>
import { reactive, ref } from 'vue';
import Taro from "@tarojs/taro";
import { storeToRefs } from "pinia";
import { useReturnTripStore } from "../../stores";
const { getReturnTripList } = useReturnTripStore();
const { returnTripList, returnTripExtra, returnTripQueries } = storeToRefs(useReturnTripStore());
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
const loading = ref(false);
const isRefreshing = ref(false);
function onRefresh() {
isRefreshing.value = true;
returnTripQueries.value.pageNum = 1;
getReturnTripList().then(() => {
Taro.stopPullDownRefresh();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
isRefreshing.value = false;
});
}
function onLoadMore() {
if (loading.value) return;
if (returnTripList.value.length >= returnTripExtra.value.total) return;
loading.value = true;
returnTripQueries.value.pageNum += 1;
getReturnTripList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
}
function onNavTo(id) {
Taro.navigateTo({
url: `/pages/returnTripMap/index?id=${id}`,
});
}
Taro.usePullDownRefresh(() => {
onRefresh();
});
Taro.useReachBottom(() => {
onLoadMore();
});
Taro.useDidShow(() => {
getReturnTripList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
});
});
</script>
<template>
<div :class="s.root">
<div class="list">
<div class="item" v-for="item in returnTripList" :key="item.recordId" :class="{ 'is-pass': item.isPass }">
<div class="title">
<span>{{ item.studentName || '-' }}</span>
<nut-button size="mini" type="info" @click="onNavTo(item.recordId)">回放</nut-button>
</div>
<div class="info">
<div class="info-row">
<span>班级{{ item.className || '-' }}</span>
<span>无人机{{ item.droneName || '-' }}</span>
</div>
<div class="info-row">
<span>教师{{ item.teacherName || '-' }}</span>
</div>
<div class="info-row">
<span>开始时间{{ item.startTime || '-' }}</span>
</div>
<div class="info-row">
<span>结束时间{{ item.endTime || '-' }}</span>
</div>
<div class="info-row">
<span><nut-tag :type="item.isPass ? 'success' : 'danger'">{{ item.isPass ? '通过' : '未通过' }}</nut-tag></span>
<span><nut-tag type="warning" v-if="item.inProgress">进行中</nut-tag></span>
</div>
</div>
</div>
<nut-empty v-if="!loading && !returnTripList.length" description="暂无数据" />
<nut-infiniteloading
v-if="returnTripList.length"
load-txt="加载中..."
load-more-txt="没有更多了"
:has-more="returnTripList.length < returnTripExtra.total"
@load-more="onLoadMore"
/>
</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">
page {
height: 100%;
padding: 20px 0;
box-sizing: border-box;
background-color: #eaeaea;
overflow: hidden;
}
.root {
height: 100%;
display: flex;
flex-direction: column;
:global {
.list {
flex: 1;
overflow: auto;
padding: 0 20px;
.item {
background-color: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 100%;
background-color: #4CAF50;
opacity: 0.8;
}
&:not(.is-pass)::before {
background-color: #FF5252;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
&:active {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
& + .item {
margin-top: 28px;
}
.title {
font-size: 38px;
font-weight: 600;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
color: #2c3e50;
.class-name {
font-size: 32px;
color: #666;
}
}
.info {
color: #666;
font-size: 28px;
line-height: 1.6;
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
&:last-child {
margin-bottom: 0;
}
span {
color: #34495e;
&:first-child {
color: #7f8c8d;
}
}
}
}
}
}
}
}
</style>

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

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '应急返航回放',
navigationStyle: 'default',
pageOrientation: 'landscape'
})

116
src/pages/returnTripMap/index.vue

@ -0,0 +1,116 @@
<script setup>
import { onMounted, ref, onUnmounted } from "vue";
import * as Taro from "@tarojs/taro";
import TrackPlayback from '../../components/TrackPlayback.vue';
import RealTimeData from '../../components/RealTimeData.vue';
import { initReturnTripConfig } from './utils';
// Home
const centerPoint = ref({
lat: 26.647661,
lng: 106.630154
});
//
let mapContext = null;
//
const polygons = ref([]);
//
const markerPoints = ref([]);
//
const polyline = ref([]);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
initReturnTrip();
});
//
function initReturnTrip() {
if (!mapContext) return;
//
const config = initReturnTripConfig(centerPoint.value);
markerPoints.value = config.markers;
polygons.value = config.polygons;
//
mapContext.includePoints({
points: [{
longitude: centerPoint.value.lng,
latitude: centerPoint.value.lat,
}],
padding: [50, 50, 110, 50],
success: (res) => {
console.log('地图视野设置成功', res);
},
fail: (err) => {
console.error('地图视野设置失败', err);
}
});
// console.log(markers);
}
//
onUnmounted(() => {
// deviceCruise.destroy();
});
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:polygons="polygons"
:markers="markerPoints"
:polyline="polyline"
:enable-satellite="true"
/>
</view>
<TrackPlayback />
<RealTimeData class="real-time-data" />
</view>
</template>
<style lang="less" module="s">
page {
height: 100%;
overflow: hidden;
}
.root {
position: relative;
width: 100%;
height: 100%;
padding: 0 !important;
overflow: hidden;
:global {
.mapBox {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
#map {
width: 100%;
height: 100%;
}
.real-time-data {
//position: absolute;
//left: 20px;
//top: 20px;
//z-index: 2;
}
}
}
</style>

96
src/pages/returnTripMap/utils.js

@ -0,0 +1,96 @@
import * as turf from '@turf/turf';
import homeBg from '../../assets/home-marker.png';
import Taro from "@tarojs/taro";
const deviceInfo = Taro.getDeviceInfo()
// 创建同心圆多边形点集
export function createCircle({ center, innerRadius, outerRadius }) {
// 配置圆形生成选项
const options = { steps: 64, units: 'meters' }; // 增加steps以使圆形更平滑
// 生成外圆和内圆
const outerCircle = turf.circle([center.lng, center.lat], outerRadius, options);
const innerCircle = turf.circle([center.lng, center.lat], innerRadius, options);
// 创建环形(外圆 - 内圆)
const featureCollection = turf.featureCollection([outerCircle, innerCircle]);
const ring = turf.difference(featureCollection);
// 转换坐标格式为小程序地图组件要求的格式
const convertCoordinates = (coords) => {
return coords.map(coord => ({
longitude: coord[0],
latitude: coord[1]
}));
};
// 获取环形的坐标点集
const ringCoords = ring.geometry.coordinates;
// 返回所需的点集
return {
ring: [...convertCoordinates(ringCoords[0]), ...convertCoordinates(ringCoords[1])],
center: {
latitude: center.lat,
longitude: center.lng
}
};
}
// 初始化返航界面配置
export function initReturnTripConfig(centerPoint, innerRadius = 50, outerRadius = 100) {
const markerOptions = {
...(`${deviceInfo?.system || ''}`.includes('iOS') ? {
color: '#FFFFFF',
fontSize: 12,
// anchorX: -5,
anchorY: -16,
textAlign: 'center',
} : {
color: '#FFFFFF',
fontSize: 12,
anchorX: -4,
anchorY: -9,
})
}
const homePoint = [centerPoint.lng, centerPoint.lat];
// 添加Home点标记
const homeMarker = [{
id: 1,
longitude: homePoint[0],
latitude: homePoint[1],
iconPath: homeBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
...markerOptions,
content: 'H'
}
}];
// 生成同心圆点集
const circlePoints = createCircle({
center: centerPoint,
innerRadius, // 内圆半径(米)
outerRadius // 外圆半径(米)
});
// 设置多边形配置
const polygons = [
{
points: circlePoints.ring,
fillColor: '#FFFFFF80',
strokeColor: '#00000000',
strokeWidth: 2
}
];
return {
markers: homeMarker,
polygons,
// homePoint: { latitude: homePoint[1], longitude: homePoint[0] }
};
}

6
src/pages/routePlan/index.config.js

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '航线规划',
disableSwipeBack: true,
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
})

221
src/pages/routePlan/index.vue

@ -0,0 +1,221 @@
<script setup>
import { reactive, ref } from 'vue';
import Taro from "@tarojs/taro";
import { storeToRefs } from "pinia";
import { useRoutePlanStore } from "../../stores";
const { getRoutePlanList } = useRoutePlanStore();
const { routePlanList, routePlanExtra, routePlanQueries } = storeToRefs(useRoutePlanStore());
const state = reactive({
msg: '错误提示',
type: 'warn',
show: false,
cover: true,
center: true,
});
function openToast(type = 'warn', msg = '错误提示') {
state.msg = msg;
state.type = type;
state.show = true;
}
const loading = ref(false);
const isRefreshing = ref(false);
function onRefresh() {
isRefreshing.value = true;
routePlanQueries.value.pageNum = 1;
getRoutePlanList().then(() => {
Taro.stopPullDownRefresh();
}).catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
isRefreshing.value = false;
});
}
function onLoadMore() {
if (loading.value) return;
if (routePlanList.value.length >= routePlanExtra.value.total) return;
loading.value = true;
routePlanQueries.value.pageNum += 1;
getRoutePlanList().catch(({ msg }) => {
if (msg) openToast('warn', msg);
}).finally(() => {
loading.value = false;
});
}
function onNavTo(id) {
Taro.navigateTo({
url: `/pages/routePlanMap/index?id=${id}`,
});
}
Taro.usePullDownRefresh(() => {
onRefresh();
});
Taro.useReachBottom(() => {
onLoadMore();
});
Taro.useDidShow(() => {
getRoutePlanList().catch(err => {
openToast('warn', err?.description);
});
});
</script>
<template>
<div :class="s.root">
<div class="list">
<div class="item" v-for="item in routePlanList" :key="item.answerId" :class="{ 'is-pass': item.isPass }">
<div class="title">
<span>{{ item.studentName || '-' }}</span>
<nut-button size="mini" type="info" @click="onNavTo(item.answerId)">查看</nut-button>
</div>
<div class="info">
<div class="info-row">
<span>题目{{ item.questionName || '-' }}</span>
<span>模板{{ item.templateName || '-' }}</span>
</div>
<div class="info-row">
<span>教师{{ item.teacherName || '-' }}</span>
</div>
<div class="info-row">
<span>开始时间{{ item.beginTime || '-' }}</span>
</div>
<div class="info-row">
<span>结束时间{{ item.finishTime || '-' }}</span>
</div>
<div class="info-row">
<span><nut-tag :type="item.isPass ? 'success' : 'danger'">{{ item.isPass ? '通过' : '未通过' }}</nut-tag></span>
</div>
<div v-if="item.errorMsg" class="error-msg">{{ item.errorMsg }}</div>
</div>
</div>
<nut-empty v-if="!loading && !routePlanList.length" description="暂无数据" />
<nut-infiniteloading
v-if="routePlanList.length"
load-txt="加载中..."
load-more-txt="没有更多了"
:has-more="routePlanList.length < routePlanExtra.total"
@load-more="onLoadMore"
/>
</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">
page {
height: 100%;
padding: 20px 0;
box-sizing: border-box;
background-color: #eaeaea;
overflow: hidden;
}
.root {
height: 100%;
display: flex;
flex-direction: column;
:global {
.list {
flex: 1;
overflow: auto;
padding: 0 20px;
.item {
background-color: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 100%;
background-color: #4CAF50;
opacity: 0.8;
}
&:not(.is-pass)::before {
background-color: #FF5252;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
&:active {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
& + .item {
margin-top: 28px;
}
.title {
font-size: 38px;
font-weight: 600;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
color: #2c3e50;
.question-name {
font-size: 32px;
color: #666;
}
}
.info {
color: #666;
font-size: 28px;
line-height: 1.6;
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
&:last-child {
margin-bottom: 0;
}
span {
color: #34495e;
&:first-child {
color: #7f8c8d;
}
}
}
}
.error-msg {
margin-top: 16px;
color: #ff4d4f;
font-size: 28px;
}
}
}
}
}
</style>

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

@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '航线规划',
navigationStyle: 'default',
pageOrientation: 'landscape'
}

433
src/pages/routePlanMap/index.vue

@ -0,0 +1,433 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import RoutePointInfo from '../../components/RoutePointInfo.vue';
import ExamResult from '../../components/ExamResult.vue';
import * as Taro from "@tarojs/taro";
// import * as turf from '@turf/turf';
import pointBg from '../../assets/point-marker.png';
import homeBg from '../../assets/home-marker.png';
import tmpBg from '../../assets/transparent-marker.png';
import { Close, Horizontal } from '@nutui/icons-vue-taro';
const deviceInfo = Taro.getDeviceInfo()
//
const isExamVisible = ref(false);
const isExamExpanded = ref(false);
//
const toggleExam = () => {
isExamVisible.value = !isExamVisible.value;
};
//
// watch(() => isExamVisible.value, (newVal) => {
// if (newVal) {
// setTimeout(() => {
// isExamExpanded.value = true;
// }, 50);
// } else {
// isExamExpanded.value = false;
// }
// });
//
const questionData = ref({
imageUrl: 'https://example.com/exam-image.jpg',
content: `航线要求:
() 起飞点返航点与考试点位的相对方位由委任代表根据现场环境等情况进行决定于起飞前规划一个等边三角形并循环执行边长为a航线相对地面高度为b水平速度为c垂直速度为d转弯方式为停止转弯停留时间不作要求
() a 值建议为30米b 值建议为30米c 值建议为3m/sd 建议为1m/s(航线方位及各数值可由委任代表按实际情况进行调整考题以委任代表规定数值为准)
() 题中心为起飞点A,地面中任取须在地图中标明1点在A的45°30米方位1到2的顺向角为135°`
});
const markerOption = computed(() => {
if (`${deviceInfo?.system || ''}`.includes('iOS')) {
return {
color: '#FFFFFF',
fontSize: 12,
// anchorX: -5,
anchorY: -16,
textAlign: 'center',
}
}
return {
color: '#FFFFFF',
fontSize: 12,
anchorX: -4,
anchorY: -9,
// textAlign: 'center',
}
})
console.log(deviceInfo.system)
Taro.hideHomeButton();
//
const centerPoint = ref({
lat: 39.908692,
lng: 116.397477
});
//
let mapContext = null;
// 线
const isClosedRoute = ref(true);
// home
const homePoint = ref({
latitude: 39.908692,
longitude: 116.397477
});
// 线
const routePoints = ref([{
latitude: 39.909192, // 1
longitude: 116.398277
}, {
latitude: 39.909892, // 4
longitude: 116.398977
}, {
latitude: 39.909392, // 2
longitude: 116.399477
}, {
latitude: 39.908892, // 3
longitude: 116.399977
}]);
//
const calculateDistance = (point1, point2) => {
const R = 6371e3; //
const φ1 = point1.latitude * Math.PI/180;
const φ2 = point2.latitude * Math.PI/180;
const Δφ = (point2.latitude - point1.latitude) * Math.PI/180;
const Δλ = (point2.longitude - point1.longitude) * Math.PI/180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
//
const midLongitude = (point1.longitude + point2.longitude) / 2;
const midLatitude = (point1.latitude + point2.latitude) / 2;
return { mid: [midLongitude, midLatitude], distance: Math.round(R * c) };
};
//
const markers = ref([
{
id: 89,
latitude: homePoint.value.latitude,
longitude: homePoint.value.longitude,
iconPath: homeBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
content: 'H',
// color: '#FFFFFF',
...markerOption.value,
// textAlign: 'center',
borderWidth: 1,
borderColor: '#000',
}
},
{
id: 1,
latitude: routePoints.value[0].latitude,
longitude: routePoints.value[0].longitude,
iconPath: pointBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
content: '1',
...markerOption.value,
}
},
{
id: 2,
latitude: routePoints.value[1].latitude,
longitude: routePoints.value[1].longitude,
iconPath: pointBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
content: '2',
...markerOption.value,
}
},
{
id: 3,
latitude: routePoints.value[2].latitude,
longitude: routePoints.value[2].longitude,
iconPath: pointBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
content: '3',
...markerOption.value,
}
},
{
id: 4,
latitude: routePoints.value[3].latitude,
longitude: routePoints.value[3].longitude,
iconPath: pointBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
label: {
content: '4',
...markerOption.value,
}
}
]);
// 线
const polyline = computed(() => {
const points = [homePoint.value, ...routePoints.value];
if (isClosedRoute.value) {
points.push({
latitude: routePoints.value[0].latitude,
longitude: routePoints.value[0].longitude
});
}
// 线
const segments = [];
for (let i = 0; i < points.length - 1; i++) {
const tmp = calculateDistance(points[i], points[i + 1]);
const midPoint = {
latitude: tmp.mid[1],
longitude: tmp.mid[0],
};
// marker
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
markers.value.push({
id: 1000 + i,
iconPath: tmpBg,
width: 20,
height: 20,
anchor: { x: 0.5, y: 0.5 },
latitude: midPoint.latitude,
longitude: midPoint.longitude,
// content: `${distance}m`
label: {
content: `${tmp.distance}m`,
color: '#FFFFFF',
fontSize: 12,
textAlign: 'center',
anchorY: -9,
}
});
}
return [{
points,
color: '#1989fa',
width: 4,
}];
});
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
//
mapContext.includePoints({
points: routePoints.value,
padding: [50, 50, 110, 50],
success: (res) => {
console.log('地图视野设置成功', res);
},
fail: (err) => {
console.error('地图视野设置失败', err);
}
});
console.log(markers);
});
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:markers="markers"
:polyline="polyline"
:enable-satellite="true"
/>
</view>
<RoutePointInfo :pointInfo="[]" />
<ExamResult :is-passed="false" />
<div :class="[s.examQuestion, { 'is-expanded': isExamVisible }]" v-show="isExamVisible">
<div class="title">
<div class="title-content">
<Horizontal />
<span>考题</span>
</div>
<Close class="close-btn" @click="toggleExam" />
</div>
<div class="content">
<div class="question-image" v-if="questionData.imageUrl">
<img :src="questionData.imageUrl" alt="考题图片" />
</div>
<div class="requirements" v-html="questionData.content"></div>
</div>
</div>
<div :class="s.showExamBtn" @click="toggleExam" v-show="!isExamVisible">
<Horizontal />
<span>显示考题</span>
</div>
</view>
</template>
<style lang="less" module="s">
page {
height: 100%;
overflow: hidden;
}
.root {
position: relative;
width: 100%;
height: 100%;
padding: 0 !important;
overflow: hidden;
:global {
.mapBox {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
#map {
width: 100%;
height: 100%;
}
}
}
.examQuestion {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 50%;
max-width: 400px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
color: #fff;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
z-index: 100;
display: flex;
flex-direction: column;
&:global {
&.is-expanded {
transform: translateX(0);
}
}
:global {
.title {
padding: 5px;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
.title-content {
display: flex;
align-items: center;
gap: 2px;
.nut-icon {
width: 20px;
height: 20px;
font-size: 12px;
}
}
.close-btn {
width: 20px;
height: 20px;
cursor: pointer;
padding: 2px;
font-size: 12px;
&:hover {
opacity: 0.8;
}
}
}
.content {
flex: 1;
padding: 12px;
overflow-y: auto;
.question-image {
margin-bottom: 12px;
img {
width: 100%;
border-radius: 8px;
}
}
.requirements {
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
}
}
}
.showExamBtn {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
z-index: 100;
font-size: 14px;
&:hover {
background: rgba(0, 0, 0, 0.9);
}
:global {
.nut-icon {
width: 20px;
height: 20px;
font-size: 12px;
}
}
}
</style>

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

@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '场地监管',
enablePullDownRefresh: false,
disableScroll: false,
})

375
src/pages/supervision/index.vue

@ -0,0 +1,375 @@
<script setup>
import { ref } from 'vue';
import Taro from '@tarojs/taro';
import deviceIcon from '../../assets/deviceIcon.png'
import TabBar from '../../components/TabBar.vue'
// import { storeToRefs } from 'pinia';
// import { useSupervisionStore } from '../../stores';
Taro.hideHomeButton();
//
const fieldList = ref([
{
recordId: 270424022978560,
studentId: 7,
studentName: '阿亮',
teacherId: 27,
teacherName: '侯老师',
droneId: 42,
droneSn: 'DJI-001',
airfield: {
id: 3,
name: '示范飞行场地2',
imageUrl: deviceIcon
},
details: [
{
recordId: 270424022978560,
happen_time: '2025-03-04 11:16:47',
phase: '悬停',
audioCode: 'welcome',
isError: true,
errorDesc: '未按指定高度悬停'
}
],
failTimes: 1,
isPass: false,
startTime: '2025-03-05 17:19:25',
endTime: '2025-03-05 17:19:35',
inProgress: false
},
{
recordId: 270424022978561,
studentId: 8,
studentName: '小明',
teacherId: 28,
teacherName: '张老师',
droneId: 43,
droneSn: 'DJI-002',
airfield: {
id: 4,
name: '高级训练场',
imageUrl: deviceIcon
},
details: [],
failTimes: 0,
isPass: true,
startTime: '2025-03-05 18:00:00',
endTime: '2025-03-05 18:15:00',
inProgress: false
},
{
recordId: 270424022978562,
studentId: 9,
studentName: '小红',
teacherId: 29,
teacherName: '王老师',
droneId: 44,
droneSn: 'DJI-003',
airfield: {
id: 5,
name: '初级训练场',
imageUrl: deviceIcon
},
details: [
{
recordId: 270424022978562,
happen_time: '2025-03-05 19:10:30',
phase: '起飞',
audioCode: 'takeoff',
isError: true,
errorDesc: '起飞速度过快'
},
{
recordId: 270424022978562,
happen_time: '2025-03-05 19:12:00',
phase: '降落',
audioCode: 'landing',
isError: true,
errorDesc: '降落点偏离指定位置'
}
],
failTimes: 2,
isPass: false,
startTime: '2025-03-05 19:10:00',
endTime: '2025-03-05 19:15:00',
inProgress: true
}
]);
// API
// const { getFieldList } = useSupervisionStore();
// const { fieldList } = storeToRefs(useSupervisionStore());
Taro.useDidShow(() => {
// 使API
// getFieldList().catch(err => {
// Taro.showToast({
// title: err?.description || '',
// icon: 'none'
// });
// });
});
function onRefresh() {
// 使API
// return getFieldList().finally(() => {
// Taro.stopPullDownRefresh();
// });
Taro.stopPullDownRefresh();
}
Taro.usePullDownRefresh(() => {
onRefresh();
});
function onNavTo(id) {
Taro.navigateTo({
url: `/pages/supervisionMap/index?id=${id}`,
});
}
</script>
<template>
<div :class="s.root">
<div class="field-list">
<div class="field-card" v-for="item in fieldList" :key="item.recordId" @click="onNavTo(item.id)">
<div class="field-image">
<img :src="item.airfield.imageUrl" alt="场地图片" />
<div class="status-badge" :class="{ 'in-progress': item.inProgress, 'passed': item.isPass, 'failed': !item.isPass && !item.inProgress }">
{{ item.inProgress ? '进行中' : (item.isPass ? '通过' : '未通过') }}
</div>
</div>
<div class="field-info">
<div class="student-info">
<div class="student-name">{{ item.studentName }}</div>
<div class="teacher-name">指导教师{{ item.teacherName }}</div>
</div>
<div class="field-name">
<span class="icon">📍</span>
{{ item.airfield.name }}
</div>
<div class="info-item">
<span class="label">飞机编号</span>
<span class="value">{{ item.droneSn || '暂无' }}</span>
</div>
<div class="info-item">
<span class="label">开始时间</span>
<span class="value">{{ item.startTime }}</span>
</div>
</div>
</div>
</div>
</div>
<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: 12px;
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;
.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 {
font-size: 14px;
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;
}
.label {
color: #666;
margin-right: 8px;
flex-shrink: 0;
min-width: 70px;
}
.value {
color: #333;
flex: 1;
}
}
}
.basic-info {
background-color: #f8f8f8;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
.info-item {
display: flex;
align-items: center;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
.label {
color: #666;
font-size: 13px;
margin-right: 8px;
flex-shrink: 0;
min-width: 70px;
}
.value {
color: #1a1a1a;
font-size: 13px;
flex: 1;
}
}
}
.error-info {
background-color: #fff2f0;
border-radius: 8px;
padding: 12px;
.error-title {
font-size: 14px;
font-weight: 500;
color: #ff4d4f;
margin-bottom: 8px;
}
.error-list {
.error-item {
padding: 8px;
background-color: rgba(255, 77, 79, 0.1);
border-radius: 4px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.error-phase {
font-size: 13px;
font-weight: 500;
color: #ff4d4f;
margin-bottom: 4px;
}
.error-desc {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.error-time {
font-size: 12px;
color: #999;
}
}
}
}
}
}
}
}
</style>

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

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

289
src/pages/supervisionMap/index.vue

@ -0,0 +1,289 @@
<script setup>
import { onMounted, ref, onUnmounted } from "vue";
import * as Taro from "@tarojs/taro";
import * as turf from '@turf/turf';
import deviceCruise from '../../core/deviceCruise';
// import TrackPlayback from '../../components/TrackPlayback.vue';
import SupervisionData from '../../components/SupervisionData.vue';
import SupervisionSideData from '../../components/SupervisionSideData.vue';
import trackData from '../flightMap/track.json';
import { convertToTrajectory, creatEightShaped } from '../flightMap/utils';
//
const centerPoint = ref({
lat: 39.908692,
lng: 116.397477
});
//
let mapContext = null;
const show = ref(true);
//
onMounted(() => {
mapContext = Taro.createMapContext('map');
show.value = true;
initMap();
initEightShaped();
});
//
const polygons = ref([]);
//
const circles = ref([]);
//
const markerPoints = ref([]);
//
const trajectory = convertToTrajectory(trackData, {
startTime: "2025-03-14T08:00:00Z",
interval: 1000 // 1
});
//
const bearing = ref(0);
//
const polyline = ref([{
points: trackData.map(coord => ({
latitude: coord[1],
longitude: coord[0]
})),
color: '#008000',
width: 4,
arrowLine: true
}]);
//
// const errorPoints = ref([{
// latitude: 39.908692,
// longitude: 116.397477,
// iconPath: '../../assets/red-marker.svg',
// width: 30,
// height: 30,
// callout: {
// content: '',
// color: '#FF0000',
// fontSize: 14,
// borderRadius: 4,
// bgColor: '#FFFFFF',
// padding: 8,
// display: 'ALWAYS'
// }
// }]);
//
function initMap() {
if (!mapContext) return;
//
}
//
// const bearing = ref(0);
//
function initEightShaped() {
if (!mapContext) return;
const center1 = [116.397477, 39.908692];
const center2 = [116.399477, 39.910692];
const mid = turf.midpoint(center1, center2).geometry.coordinates;
console.log('mid', mid);
centerPoint.value = {
lat: mid[1],
lng: mid[0]
};
//
let angle = turf.bearing(center1, center2);
//
angle = ((90 - angle) % 360 + 360) % 360;
const { polygons: shapePolygons, circles: shapeCircles, markers: shapeMarkers } = creatEightShaped({
center: center1,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, {
center: center2,
radius: 105,
radiusDiff: 35,
centerWidth: 2,
}, [6, 0, 1, 3, 4, 5, 2], { radius: 50 });
//
polygons.value = shapePolygons;
markerPoints.value = shapeMarkers;
circles.value = shapeCircles;
//
mapContext.includePoints({
points: [
{ latitude: center1[1], longitude: center1[0] },
{ latitude: center2[1], longitude: center2[0] }
],
padding: [50, 50, 110, 50],
success: (res) => {
setTimeout(() => {
// 使
bearing.value = angle;
// console.log('angle', bearing.value);
}, 5000);
// bearing.value = angle.toFixed(2) - 85;
console.log('地图视野设置成功', res);
},
fail: (err) => {
console.error('地图视野设置失败', err);
}
});
}
//
onUnmounted(() => {
if (deviceCruise) {
deviceCruise.destroy();
}
});
</script>
<template>
<view :class="s.root">
<view class="mapBox">
<map
id="map"
:longitude="centerPoint.lng"
:latitude="centerPoint.lat"
:markers="markerPoints"
:polygons="polygons"
:circles="circles"
:polyline="polyline"
:enable-rotate="true"
:rotate="bearing"
:enable-satellite="true"
/>
<view v-if="show" class="exam-result-modal">
<view class="modal-content">
<view class="close-btn" @tap="show = false">×</view>
<view class="result-status" :class="{ 'pass': true }">
通过
</view>
<view class="info-item">
<text class="label">姓名</text>
<text class="value">张三</text>
</view>
<view class="info-item">
<text class="label">飞机编号</text>
<text class="value">UAV-001</text>
</view>
</view>
</view>
</view>
<SupervisionData class="real-time-data" />
<SupervisionSideData />
</view>
</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%;
}
.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>

14
src/stores/index.js

@ -0,0 +1,14 @@
/**
* stores
*/
import { createPinia } from 'pinia';
const store = createPinia();
export default store;
export * from './modules/authStore';
export * from './modules/flightStore';
export * from './modules/routePlanStore';
export * from './modules/returnTripStore';
export * from './modules/evaluationStore';

138
src/stores/modules/authStore.js

@ -0,0 +1,138 @@
/**
* authStore
*/
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as helpers from '../../utils/helpers';
import * as UserInfo from '../../utils/userInfo';
import * as auth from '../../utils/auth';
import * as urls from '../../config/urls';
export const useAuthStore = defineStore('auth', () => {
// state
const userInfo = ref(UserInfo.get());
const checkRole = (roleCode) => computed(() => {
if (!userInfo.value?.roles) return false;
return userInfo.value.roles.find(({ code }) => code === roleCode);
});
const isSuperAdmin = checkRole('super_admin');
const isOrgAdmin = checkRole('org_admin');
const isTeacherAdmin = checkRole('teacher_admin');
const isEduAdmin = checkRole('edu_admin');
const isTeacher = checkRole('teacher');
const isStudent = checkRole('student');
// actions
function loginWithPassword(formData = {}) {
const requestDate = helpers.pick(formData, [
'password',
'phone',
]);
return http.post(urls.LOGIN_WITH_PASSWORD, requestDate, {
withToken: false
}).then(({ data: { data } = {} }) => {
// const { data = {} } = data;
// 获取信息
console.log('loginWithPassword', data);
const { token: accessToken, user } = data;
if (accessToken) {
auth.saveToken(accessToken);
}
// 更新信息
const newUserInfo = {
// ...userInfo.value,
...(user || {}),
};
userInfo.value = { ...newUserInfo };
// // 保存信息
UserInfo.save(newUserInfo);
return data;
});
}
function wechatAuth({ code, sceneId } = {}) {
return http.post(urls.WECHAT_AUTH_URL, {
code,
sceneId
}, {
withToken: false
}).then(({ data: { data } = {} }) => {
console.log('wechatAuth', data);
const { token: accessToken = '', student = {}, user = {} } = data || {};
if (accessToken) {
auth.saveToken(accessToken);
// 更新信息
const newUserInfo = {
// ...userInfo.value,
...(user || {}),
...(student || {}),
};
userInfo.value = { ...newUserInfo };
// // 保存信息
UserInfo.save(newUserInfo);
}
return data;
});
}
function getWechatUserInfo({ code, iv, encryptedData } = {}) {
return http.post(urls.GET_WECHAT_USERINFO, {
code,
iv,
encryptedData,
}).then(({ data: { data } = {} }) => {
console.log('wechatuserInfo', data);
return data;
});
}
function updateStudentAvatar(formData = {}) {
const requestData = helpers.pick(formData, [
'id',
'avatar',
]);
return http.put(urls.UPDATE_STUDENT(requestData.id), requestData).then(({ data }) => {
return data;
});
}
function updateMemberAvatar(formData = {}) {
const requestData = helpers.pick(formData, [
'id',
'avatar',
]);
return http.put(urls.UPDATE_MEMBER(requestData.id), requestData).then(({ data }) => {
return data;
});
}
function uploadFile(formData, { header = {} } = {}) {
return http.post(urls.UPLOAD, formData, { header }).then(({ data }) => {
return data;
});
}
return {
userInfo,
loginWithPassword,
isSuperAdmin,
isOrgAdmin,
isTeacherAdmin,
isEduAdmin,
isTeacher,
isStudent,
wechatAuth,
updateStudentAvatar,
updateMemberAvatar,
getWechatUserInfo,
uploadFile
};
});
export default {};

61
src/stores/modules/evaluationStore.js

@ -0,0 +1,61 @@
/**
* evaluationStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as urls from '../../config/urls';
export const useEvaluationStore = defineStore('evaluation', () => {
// state
const evaluationList = ref([]);
const evaluationExtra = ref({ total: null });
const evaluationQueries = ref({ pageNum: 1, pageSize: 10 });
// actions
function getEvaluationList(params = {}) {
const queryParams = { ...evaluationQueries.value, ...params };
return http.get(urls.GET_EVALUATION_LIST, { params: queryParams }).then(({ data: { data } = {} }) => {
const { records: list = [], total = 0 } = data;
evaluationList.value = list || [];
evaluationExtra.value = { ...evaluationExtra.value, total };
evaluationQueries.value = { ...evaluationQueries.value, ...queryParams };
return data;
});
}
function createEvaluation(params) {
return http.post(urls.CREATE_EVALUATION, params).then(({ data }) => {
return data;
});
}
function updateEvaluation(params) {
return http.put(urls.UPDATE_EVALUATION, params).then(({ data }) => {
return data;
});
}
function getEvaluationDetail(id) {
return http.get(urls.GET_EVALUATION_DETAIL(id)).then(({ data }) => {
return data;
});
}
function replyEvaluation(evaluationId, content) {
return http.post(urls.REPLY_EVALUATION, { evaluationId, content }).then(({ data }) => {
return data;
});
}
return {
evaluationList,
evaluationExtra,
evaluationQueries,
getEvaluationList,
createEvaluation,
updateEvaluation,
getEvaluationDetail,
replyEvaluation
};
});

49
src/stores/modules/flightStore.js

@ -0,0 +1,49 @@
/**
* flightStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as urls from '../../config/urls';
export const useFlightStore = defineStore('flight', () => {
// state
const flightList = ref([]);
const flightExtra = ref({ total: null });
const flightQueries = ref({ pageNum: 1, pageSize: 10 });
// actions
function getFlightList(params = {}) {
const queryParams = { ...flightQueries.value, ...params };
return http.get(urls.GET_FLIGHT_LIST, { params: queryParams }).then(({ data: { data } = {} }) => {
const { records: list = [], total = 0 } = data;
flightList.value = list || [];
flightExtra.value = { ...flightExtra.value, total };
flightQueries.value = { ...flightQueries.value, ...queryParams };
return data;
});
}
function getFlightDetail(id) {
return http.get(urls.GET_FLIGHT_DETAIL(id)).then(({ data }) => {
return data;
});
}
function getFlightTracks(id) {
return http.get(urls.GET_FLIGHT_TRACKS(id)).then(({ data }) => {
return data;
});
}
return {
flightList,
flightExtra,
flightQueries,
getFlightList,
getFlightDetail,
getFlightTracks
};
});

40
src/stores/modules/returnTripStore.js

@ -0,0 +1,40 @@
/**
* returnTripStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as urls from '../../config/urls';
export const useReturnTripStore = defineStore('returnTrip', () => {
// state
const returnTripList = ref([]);
const returnTripExtra = ref({ total: null });
const returnTripQueries = ref({ pageNum: 1, pageSize: 10 });
// actions
function getReturnTripList(params = {}) {
const queryParams = { ...returnTripQueries.value, ...params };
return http.get(urls.GET_RETURN_TRIP_LIST, { params: queryParams }).then(({ data: { data } = {} }) => {
const { records: list = [], total = 0 } = data;
returnTripList.value = list || [];
returnTripExtra.value = { ...returnTripExtra.value, total };
returnTripQueries.value = { ...returnTripQueries.value, ...queryParams };
return data;
});
}
function getReturnTripDetail(id) {
return http.get(urls.GET_RETURN_TRIP_DETAIL(id)).then(({ data }) => {
return data;
});
}
return {
returnTripList,
returnTripExtra,
returnTripQueries,
getReturnTripList,
getReturnTripDetail
};
});

40
src/stores/modules/routePlanStore.js

@ -0,0 +1,40 @@
/**
* routePlanStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '../../utils/http';
import * as urls from '../../config/urls';
export const useRoutePlanStore = defineStore('routePlan', () => {
// state
const routePlanList = ref([]);
const routePlanExtra = ref({ total: null });
const routePlanQueries = ref({ pageNum: 1, pageSize: 10 });
// actions
function getRoutePlanList(params = {}) {
const queryParams = { ...routePlanQueries.value, ...params };
return http.get(urls.GET_ROUTE_PLAN_LIST, { params: queryParams }).then(({ data: { data } = {} }) => {
const { records: list = [], total = 0 } = data;
routePlanList.value = list || [];
routePlanExtra.value = { ...routePlanExtra.value, total };
routePlanQueries.value = { ...routePlanQueries.value, ...queryParams };
return data;
});
}
function getRoutePlanDetail(id) {
return http.get(urls.GET_ROUTE_PLAN_DETAIL(id)).then(({ data }) => {
return data;
});
}
return {
routePlanList,
routePlanExtra,
routePlanQueries,
getRoutePlanList,
getRoutePlanDetail
};
});

51
src/stores/modules/supervisionStore.js

@ -0,0 +1,51 @@
/**
* supervisionStore
* 场地监管相关接口
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import * as urls from '../../config/urls';
import http from '../../utils/http';
export const useSupervisionStore = defineStore('supervision', () => {
const fieldList = ref([]);
const flightQueries = ref({ pageNum: 1, pageSize: 99999, inProgress: true });
// actions
function getFieldList() {
return http.get(urls.GET_FLIGHT_LIST, { params: flightQueries }).then(({ data: { data } = {} }) => {
const { records: list = [], total = 0 } = data;
// 获取所有记录的详情信息
const detailPromises = list.map(record => {
return http.get(urls.GET_FLIGHT_DETAIL(record.id)).then(({ data }) => {
return { ...record, ...data };
});
});
// 使用Promise.all并行处理所有详情请求
return Promise.all(detailPromises).then(detailedList => {
fieldList.value = detailedList;
return { ...data, records: detailedList };
});
});
}
// function getFieldDetail(id) {
// return http.get(urls.GET_FLIGHT_DETAIL(id)).then(({ data }) => {
// return data;
// });
// }
// function getFlightTracks(id) {
// return http.get(urls.GET_FLIGHT_TRACKS(id)).then(({ data }) => {
// return data;
// });
// }
return {
fieldList,
getFieldList,
};
});

34
src/utils/auth.js

@ -0,0 +1,34 @@
/**
* token信息存取
*/
import { setStorageSync, getStorageSync, removeStorageSync } from "@tarojs/taro";
export function saveToken(accessToken) {
try {
setStorageSync('token', accessToken)
} catch (e) {
// Do something when catch error
console.log('e', e);
}
}
export function getToken() {
let token;
try {
token = getStorageSync('token');
} catch (e) {
// Do something when catch error
console.log('e', e);
}
return token;
}
export function removeToken() {
try {
removeStorageSync('token')
} catch (e) {
// Do something when catch error
console.log('e', e);
}
}

6
src/utils/eventBus.js

@ -0,0 +1,6 @@
/**
* 事件总线用于跨组件通信一般用于非父子组件
*/
// import mitt from 'mitt';
// export default mitt();

210
src/utils/helpers.js

@ -0,0 +1,210 @@
/**
* 辅助函数
*/
import dayjs from 'dayjs';
/**
* 等待异步结果
* @param checker 检测函数返回值为truthy时表示得到想要的结果否则反之
* @param timeout 指定时间内没有得到想要的结果则超时
*/
export function until(checker = () => true, timeout = 2000) {
return new Promise((resolve) => {
let pollingTimer = null;
const timeoutTimer = setTimeout(() => {
clearInterval(pollingTimer);
resolve(false);
}, timeout);
pollingTimer = setInterval(() => {
const result = checker();
if (result) {
clearTimeout(timeoutTimer);
clearInterval(pollingTimer);
resolve(result);
}
}, 10);
});
}
/**
* 根据restful的uri构建实际url
* @param uri uri模版
* @param argv 插入到uri中的参数列表按顺序插入
* @returns url
*/
export function buildURL(uri, ...argv) {
return uri.replace(/{\w+}|:[a-zA-Z]+/g, () => {
const res = argv.shift();
if (res === undefined) {
throw new Error('URI 参数不足');
}
return res;
});
}
/**
* 选取原object指定属性构成新的object
* @param originObject
* @param pickKeys
*/
export function pick(originObject, pickKeys = []) {
const newObject = {};
pickKeys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(originObject, key)) {
newObject[key] = originObject[key];
}
});
return newObject;
}
/**
* 更新target对象的键值使用source对象里的数据
* @param target
* @param source
*/
export function update(target, source) {
const result = { ...target };
Object.keys(target).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(source, key)) {
result[key] = source[key];
}
});
return result;
}
/**
* 将对象的部分key重命名
* @param originObject 原始对象也可以是相同结构的对象构成的数组
* @param keysMapping 新keys映射{ name: 'username', sex: 'gender' }表示把原始对象中的name重命名为username把sex重命名为gender原始对象其他key不变
* @returns {{}|*} 新对象
*/
export function renameKeys(originObject, keysMapping = {}) {
if (!Array.isArray(originObject)) {
const kvList = Object.keys(originObject).map((key) => {
const newKey = keysMapping[key] || key;
return { [newKey]: originObject[key] };
});
return Object.assign({}, ...kvList);
}
return originObject.map((obj) => renameKeys(obj, keysMapping));
}
/**
* 随机数
*/
export function rand(min, max, fraction = 0) {
const res = (Math.random() * ((max - min) + 1)) + min;
return res.toFixed(fraction) - 0;
}
/**
* 将input当着数字处理若不是数字则返回代替字符
* @param input
* @param substitution
* @returns {string|number}
*/
export function numeric(input, substitution = '-') {
const type = typeof input;
if (type !== 'number' && type !== 'string') return substitution;
const output = Number(input);
return !Number.isNaN(output) ? output : substitution;
}
// 保留小数位(是整数则不保留)
export const toFixed = (num, digits = 2) => (Number(num || 0).toFixed(digits)) - 0;
export const falsyTo = (fv, to) => (fv || fv === 0 ? fv : to);
/**
* 格式化时间戳
* @param timestamp
* @param format
* @returns {string}
*/
export function formatTime(timestamp, format = 'YYYY-MM-DD HH:mm:ss') {
return (timestamp || timestamp === 0) && dayjs(timestamp).isValid() ? dayjs(timestamp).format(format) : '-';
}
/**
* 将总秒数格式化为x时y分z秒形式
* @param sec
* @param precision
* @param isChinese
* @returns {string}
*/
export function popularTime(sec, precision = 0, isChinese = false) {
const { HOUR, MINUTE, SECOND } = isChinese ? { HOUR: '时', MINUTE: '分', SECOND: '秒' } : {
HOUR: 'h',
MINUTE: 'm',
SECOND: 's'
};
let remainingSec = sec;
const hours = Math.floor(remainingSec / 3600);
remainingSec -= hours * 3600;
const minutes = Math.floor(remainingSec / 60);
remainingSec -= minutes * 60;
return `${hours ? `${hours}${HOUR}` : ''}${minutes ? `${minutes}${MINUTE}` : ''}${remainingSec ? `${remainingSec.toFixed(precision)}${SECOND}` : ''}`;
}
/**
* 将秒转化为小时
* @param sec
* @returns {number}
*/
export function toHour(sec) {
return (sec / 3600).toFixed(2) - 0;
}
/**
* 对数组中的每个元素进行偏移计算返回计算后的数组
* @param originArray 原数组有数字构成
* @param offsetArray 偏移量数组与原数组元素一一对应
* @returns {*[]} 计算后的新数组
*/
export function arrOffset(originArray = [], offsetArray = []) {
return originArray.map((item, index) => +item + (offsetArray[index] || 0));
}
/**
* 范围时间内生成连续时间戳按步长连续
* @param startTs 起始时间戳
* @param endTs 结束时间戳
* @param step 步长year | month | week | day | hour | minute | second | millisecond
* @returns {*[]} 连续时间戳
*/
export function rangeTimestamps(startTs, endTs, step = 'day') {
const s = dayjs(startTs).startOf(step);
const eTs = dayjs(endTs).startOf(step).valueOf();
const result = [];
for (; s.valueOf() <= eTs;) {
result.push(s.valueOf());
s.add(1, step);
}
return result;
}
/**
* 计算出数组中最多的元素
* @param array
* @returns {*}
*/
export function mostCommonElement(array) {
return array.reduce((max, element) => {
const count = array.filter((e) => e === element).length;
return count > array.filter((e) => e === max).length ? element : max;
}, '');
}
// /**
// * 将gps坐标,转换为国测局坐标
// * @param point 经纬度构成的数组[经度, 纬度]
// */
// export function GPS2GCJ(point) {
// return gcoord.transform(point, gcoord.WGS84, gcoord.GCJ02);
// }

209
src/utils/http.js

@ -0,0 +1,209 @@
/**
* 请求
*/
import Taro, { request, downloadFile, uploadFile } from "@tarojs/taro";
import { getToken } from './auth';
// import { log } from "console";
// import * as UserInfo from "./userInfo";
// import * as Auth from "./auth";
// function judgmentHttpCode(res, reject, resolve) {
// if (res?.statusCode !== 200 || res?.data?.code !== 200) {
// reject(res?.data);
// if (res?.data?.code === 40011) {
// UserInfo.removeUserInfo();
// Auth.removeToken();
// Taro.reLaunch({ url: '/pages/login/index?refresh=true' })
// }
// } else {
// resolve(res?.data || { code: SUCCESS_CODE, description: "无数据返回", data: {} });
// }
// }
class Http {
get = (url, { params = {}, header = {}, withToken = true } = {}) => {
return new Promise((resolve, reject) => {
request({
url,
header: {
...(withToken ? {
Authorization: `Bearer ${getToken()}`
} : {}),
...header,
},
data: params,
method: 'GET',
success: (res) => {
const { data, statusCode } = res || {};
if (statusCode !== 200) {
reject(data);
return;
}
resolve({ data });
},
fail: (err) => {
reject({ msg: err.errMsg || '请求失败' });
}
})
})
}
post = (url, data = {}, { header = {}, withToken = true } = {}) => {
return new Promise((resolve, reject) => {
request({
url,
header: {
...(withToken ? {
Authorization: `Bearer ${getToken()}`
} : {}),
...header,
},
data,
method: 'POST',
success: (res) => {
const { data, statusCode } = res || {};
if (statusCode !== 200) {
reject(data);
return;
}
resolve({ data });
},
fail: (err) => {
reject({ msg: err.errMsg || '请求失败' });
}
})
});
}
put = (url, data = {}, { header = {}, withToken = true } = {}) => {
return new Promise((resolve, reject) => {
request({
url,
header: {
...(withToken ? {
Authorization: `Bearer ${getToken()}`
} : {}),
...header,
},
data,
method: 'PUT',
success: (res) => {
const { data, statusCode } = res || {};
if (statusCode !== 200) {
reject(data);
return;
}
resolve({ data });
},
fail: (err) => {
reject({ msg: err.errMsg || '请求失败' });
}
})
});
}
delete = (url, data = {}, { header = {}, withToken = true } = {}) => {
return new Promise((resolve, reject) => {
request({
url,
header: {
...(withToken ? {
Authorization: `Bearer ${getToken()}`
} : {}),
...header,
},
data,
method: 'DELETE',
success: (res) => {
const { data, statusCode } = res || {};
if (statusCode !== 200) {
reject(data);
return;
}
resolve({ data });
},
fail: (err) => {
reject({ msg: err.errMsg || '请求失败' });
}
})
});
}
// downLoadFile = (url, data = {}, { header = {}, withToken = true } = {}) => {
// return new Promise((resolve, reject) => {
// downloadFile({
// url,
// header: {
// ...(withToken ? {
// Authorization: `Bearer ${getToken()}`
// } : {}),
// ...header,
// },
// // data,
// // method: 'DELETE',
// success: (res) => {
// // console.log('downloadFile', res);
// if (res?.data) {
// reject(res?.data);
// } else {
// const str = res.header["Content-disposition"];
// // console.log('str', str);
// const regex = /filename\*=utf-8''([^;]+)/;
// const match = `${str}`.match(regex);
//
// let fileName = '未命名文件';
// if (match && match[1]) {
// fileName = decodeURIComponent(match[1]);
// }
// resolve({ code: SUCCESS_CODE, data: { filePath: res.tempFilePath, fileName } });
// }
// // if (res?.statusCode !== 200 || res?.data?.code !== 200) {
// // reject(res?.data);
// // } else {
// // resolve(res?.data || { code: SUCCESS_CODE, description: "无数据返回", data: {} });
// // }
// },
// fail: (err) => {
// reject({ code: err?.errno || 0, description: err?.errMsg || '请求发送失败', data: {} });
// }
// })
// });
// }
//
// upLoad = (url, data = {}, { header = {}, withToken = true } = {}) => {
// return new Promise((resolve, reject) => {
// uploadFile({
// url,
// header: {
// ...(withToken ? {
// Authorization: `Bearer ${getToken()}`
// } : {}),
// ...header,
// },
// filePath: data.filePath,
// name: 'no',
// formData: data,
// // data,
// // method: 'DELETE',
// success: (res) => {
// console.log('downloadFile', res);
// if (res?.data) {
// reject(res?.data);
// } else {
// resolve({ code: SUCCESS_CODE, data: res.tempFilePath })
// }
// // if (res?.statusCode !== 200 || res?.data?.code !== 200) {
// // reject(res?.data);
// // } else {
// // resolve(res?.data || { code: SUCCESS_CODE, description: "无数据返回", data: {} });
// // }
// },
// fail: (err) => {
// reject({ code: err?.errno || 0, description: err?.errMsg || '请求发送失败', data: {} });
// }
// })
// });
// }
}
export default new Http();

34
src/utils/userInfo.js

@ -0,0 +1,34 @@
/**
* 身份信息存取
*/
import { setStorageSync, getStorageSync, removeStorageSync } from "@tarojs/taro";
export function save(userInfo) {
try {
// JSON.stringify(userInfo)
setStorageSync('userInfo', userInfo)
} catch (e) {
// Do something when catch error
console.log('e', e);
}
}
export function get() {
let userInfo;
try {
userInfo = getStorageSync('userInfo');
} catch (e) {
// Do something when catch error
console.log('e', e);
}
return userInfo;
}
export function remove() {
try {
removeStorageSync('userInfo')
} catch (e) {
// Do something when catch error
console.log('e', e);
}
}
Loading…
Cancel
Save