Browse Source

Initial commit: laic frontend Vue project

main
刘浩东 1 month ago
commit
01bdf0eb17
  1. 4
      .gitignore
  2. 12
      index.html
  3. 1690
      package-lock.json
  4. 23
      package.json
  5. BIN
      public/assets/login-hero-dock-drone.png
  6. BIN
      public/assets/map-base-clean.png
  7. BIN
      public/assets/map-base.png
  8. BIN
      public/assets/reference-map-source.png
  9. 65
      src/App.vue
  10. 45
      src/api/request.js
  11. 91
      src/components/DockEditModal.vue
  12. 130
      src/layout/AppShell.vue
  13. 10
      src/main.js
  14. 56
      src/router/index.js
  15. 313
      src/store/devices.js
  16. 33
      src/store/ui.js
  17. 27
      src/store/user.js
  18. 893
      src/styles/prototype.css
  19. 413
      src/views/Account.vue
  20. 136
      src/views/Alarms.vue
  21. 152
      src/views/Control.vue
  22. 593
      src/views/DeviceDetail.vue
  23. 512
      src/views/Devices.vue
  24. 266
      src/views/Firmwares.vue
  25. 197
      src/views/Login.vue
  26. 145
      src/views/Media.vue
  27. 479
      src/views/Monitor.vue
  28. 159
      src/views/OperationLogs.vue
  29. 127
      src/views/Replay.vue
  30. 88
      src/views/System.vue
  31. 582
      src/views/Tasks.vue
  32. 126
      src/views/Users.vue
  33. 17
      vite.config.js

4
.gitignore

@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store

12
index.html

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>嘉谷低空智控平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1690
package-lock.json

File diff suppressed because it is too large

23
package.json

@ -0,0 +1,23 @@
{
"name": "laic-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8",
"element-plus": "^2.7.3",
"pinia": "^2.1.7",
"vue": "^3.4.27",
"vue-router": "^4.3.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.11"
}
}

BIN
public/assets/login-hero-dock-drone.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
public/assets/map-base-clean.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
public/assets/map-base.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/assets/reference-map-source.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

65
src/App.vue

@ -0,0 +1,65 @@
<template>
<svg class="icon-library" aria-hidden="true">
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
<symbol id="i-radio" viewBox="0 0 24 24"><path d="M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.5M12 12h.01M16.2 7.8a6 6 0 0 1 0 8.5M19.1 4.9a10 10 0 0 1 0 14.2"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5 9 5 9-5Z"/><path d="m3 8 9 5 9-5M3 12l9 5 9-5M3 16l9 5 9-5"/></symbol>
<symbol id="i-route" viewBox="0 0 24 24"><circle cx="6" cy="19" r="3"/><path d="M9 19h6.5a3.5 3.5 0 0 0 0-7h-7a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><path d="m16 13 5 3V8l-5 3"/><rect width="13" height="12" x="3" y="6" rx="2"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M13.7 21h-3.4"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="i-map-pin" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol>
<symbol id="i-wind" viewBox="0 0 24 24"><path d="M4 8h10a2 2 0 1 0-2-2M4 12h16M4 16h10a2 2 0 1 1-2 2"/></symbol>
<symbol id="i-cloud-rain" viewBox="0 0 24 24"><path d="M16 13a4 4 0 1 0-3.8-5.3A5 5 0 1 0 6 15h10a3 3 0 0 0 0-6"/><path d="m8 19-1 2M12 19l-1 2M16 19l-1 2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></symbol>
<symbol id="i-maximize" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m6 3 14 9-14 9V3Z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1"/></symbol>
<symbol id="i-plane" viewBox="0 0 24 24"><path d="M17.8 19 14 12.8V7l2-2V3l-4 1-4-1v2l2 2v5.8L6.2 19 10 18l2 3 2-3 3.8 1Z"/></symbol>
<symbol id="i-home" viewBox="0 0 24 24"><path d="m3 11 9-8 9 8"/><path d="M5 10v10h14V10M9 20v-6h6v6"/></symbol>
<symbol id="i-battery" viewBox="0 0 24 24"><rect width="18" height="12" x="2" y="6" rx="2"/><path d="M22 10v4M6 10h8"/></symbol>
<symbol id="i-satellite" viewBox="0 0 24 24"><path d="m13 7 4-4 4 4-4 4M11 9l4 4M6 14l4 4M5 13l6-6M3 17l4 4M12 22a10 10 0 0 0-10-10"/></symbol>
<symbol id="i-door" viewBox="0 0 24 24"><path d="M4 21V4a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v17M4 21h16M9 3v18M14 12h.01"/></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 6v6h-6M4 18v-6h6"/><path d="M18.5 9A7 7 0 0 0 6.2 6.2L4 9M5.5 15A7 7 0 0 0 17.8 17.8L20 15"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
<symbol id="i-calendar" viewBox="0 0 24 24"><path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="17" rx="2"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 20h16"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></symbol>
<symbol id="i-wallet" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><path d="M3 10h18M16 15h2"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="2.5"/></symbol>
<symbol id="i-phone" viewBox="0 0 24 24"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 5h4M11 18h2"/></symbol>
<symbol id="i-message" viewBox="0 0 24 24"><path d="M4 5h16v11H8l-4 4V5Z"/><path d="M8 9h8M8 12h5"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 20 6v5c0 5-3.4 8.2-8 10-4.6-1.8-8-5-8-10V6l8-3Z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></symbol>
</svg>
<router-view />
<div class="modal-backdrop" :class="{ open: ui.confirmVisible }" :aria-hidden="!ui.confirmVisible">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-icon"><svg><use href="#i-alert"/></svg></div>
<h3>{{ ui.confirmTitle }}</h3>
<p>{{ ui.confirmDesc }}</p>
<div class="safety-checks"><span v-for="c in ui.confirmChecks" :key="c"><i></i>{{ c }}</span></div>
<div class="modal-actions"><button class="cancel" @click="ui.resolveConfirm(false)">取消</button><button class="confirm" @click="ui.resolveConfirm(true)">确认执行</button></div>
</div>
</div>
<div class="toast" :class="{ show: ui.toastVisible }"><span></span><strong>{{ ui.toastMsg }}</strong></div>
</template>
<script setup>
import { useUiStore } from './store/ui'
const ui = useUiStore()
</script>

45
src/api/request.js

@ -0,0 +1,45 @@
import axios from 'axios'
import { useUserStore } from '../store/user'
import { useUiStore } from '../store/ui'
import router from '../router'
const request = axios.create({
baseURL: '/api',
timeout: 15000
})
request.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
request.interceptors.response.use(
(response) => {
const res = response.data
if (res && res.code === 200) {
return res.data
}
useUiStore().toast(res?.msg || '请求失败')
return Promise.reject(new Error(res?.msg || '请求失败'))
},
(error) => {
const status = error.response?.status
const data = error.response?.data
const userStore = useUserStore()
if (status === 401) {
userStore.logout()
router.push('/login')
useUiStore().toast(data?.msg || '未登录,请重新登录')
} else if (status === 403) {
useUiStore().toast(data?.msg || '无权限访问')
} else {
useUiStore().toast(data?.msg || error.message || '网络错误')
}
return Promise.reject(error)
}
)
export default request

91
src/components/DockEditModal.vue

@ -0,0 +1,91 @@
<template>
<div class="modal-backdrop" :class="{ open: visible }" @click.self="close">
<div class="modal add-dock-dialog" role="dialog" aria-modal="true" aria-label="编辑机巢">
<header>
<div><span>设备管理</span><h3>编辑机巢</h3></div>
<button class="icon-button" type="button" :disabled="saving" @click="close"><svg><use href="#i-x" /></svg></button>
</header>
<form novalidate @submit.prevent="submit">
<label><span>机巢名称<b>*</b></span><input v-model="form.name" type="text" maxlength="64" placeholder="例如:青山湖 03 号机巢" /></label>
<label><span>机巢 SN</span><input :value="dock?.sn || ''" type="text" readonly /></label>
<label><span>通信卡 ICCID</span><input :value="dock?.iccid || ''" type="text" readonly /></label>
<div class="form-row">
<label><span>经度<b>*</b></span><input v-model="form.longitude" type="number" min="-180" max="180" step="any" placeholder="例如:120.219375" /></label>
<label><span>纬度<b>*</b></span><input v-model="form.latitude" type="number" min="-90" max="90" step="any" placeholder="例如:30.259244" /></label>
</div>
<label><span>设备位置<em>选填</em></span><input v-model="form.location" type="text" maxlength="128" placeholder="例如:青山湖科技城 C 区" /></label>
<div class="form-error">{{ error }}</div>
<footer>
<button class="plain-command" type="button" :disabled="saving" @click="close">取消</button>
<button class="primary-command" type="submit" :disabled="saving">{{ saving ? '保存中…' : '保存修改' }}</button>
</footer>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import request from '../api/request'
const props = defineProps({
visible: Boolean,
dock: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const form = reactive({ name: '', longitude: '', latitude: '', location: '' })
const error = ref('')
const saving = ref(false)
watch(() => props.visible, (visible) => {
if (!visible || !props.dock) return
Object.assign(form, {
name: props.dock.name || '',
longitude: props.dock.longitude ?? '',
latitude: props.dock.latitude ?? '',
location: props.dock.location === '--' ? '' : props.dock.location || ''
})
error.value = ''
})
function close() {
if (!saving.value) emit('close')
}
async function submit() {
const name = form.name.trim()
const longitude = Number(form.longitude)
const latitude = Number(form.latitude)
error.value = ''
if (!name || form.longitude === '' || form.latitude === '') {
error.value = '请填写机巢名称、经度和纬度。'
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
error.value = '经度应在 -180~180 之间。'
return
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
error.value = '纬度应在 -90~90 之间。'
return
}
if (!props.dock?.id) return
saving.value = true
try {
await request.put(`/v1/docks/${props.dock.id}`, {
name,
longitude,
latitude,
location: form.location.trim()
})
emit('saved')
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
</script>

130
src/layout/AppShell.vue

@ -0,0 +1,130 @@
<template>
<div class="app-shell">
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="brand">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div><strong>嘉谷</strong><small>低空智控平台</small></div>
</div>
<nav class="main-nav" aria-label="主导航">
<button class="nav-item" :class="{ active: isActive('monitor') }" @click="go('monitor')"><svg><use href="#i-radio"/></svg><span>实时监控</span></button>
<button class="nav-item" :class="{ active: isActive('devices') || isActive('device-detail') || isActive('control') }" @click="go('devices')"><svg><use href="#i-box"/></svg><span>设备管理</span></button>
<div v-if="!isAdmin" class="nav-group" :class="{ open: taskOpen }">
<button class="nav-item" :class="{ active: isActive('tasks') || isActive('replay'), expanded: taskOpen }" @click="toggleTask">
<svg><use href="#i-route"/></svg><span>任务管理</span><svg class="nav-arrow"><use href="#i-chevron"/></svg>
</button>
<div class="subnav">
<button class="subnav-item" :class="{ active: taskView === 'plans' }" @click="go('tasks', { view: 'plans' })"><span></span>任务计划</button>
<button class="subnav-item" :class="{ active: taskView === 'records' }" @click="go('tasks', { view: 'records' })"><span></span>执行记录</button>
<button class="subnav-item" :class="{ active: taskView === 'routes' }" @click="go('tasks', { view: 'routes' })"><span></span>航线库</button>
</div>
</div>
<div v-if="!isAdmin" class="nav-group" :class="{ open: mediaOpen }">
<button class="nav-item" :class="{ active: isActive('media'), expanded: mediaOpen }" @click="toggleMedia">
<svg><use href="#i-video"/></svg><span>媒体中心</span><svg class="nav-arrow"><use href="#i-chevron"/></svg>
</button>
<div class="subnav">
<button class="subnav-item" :class="{ active: mediaView === 'live' }" @click="go('media', { view: 'live' })"><span></span>直播中心</button>
<button class="subnav-item" :class="{ active: mediaView === 'videos' }" @click="go('media', { view: 'videos' })"><span></span>原始视频</button>
</div>
</div>
<button v-if="!isAdmin" class="nav-item" :class="{ active: isActive('alarms') }" @click="go('alarms')"><svg><use href="#i-alert"/></svg><span>告警运维</span></button>
<button v-if="!isAdmin" class="nav-item" :class="{ active: isActive('account') }" @click="go('account')"><svg><use href="#i-wallet"/></svg><span>账户中心</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('users') }" @click="go('users')"><svg><use href="#i-user"/></svg><span>用户列表</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('firmwares') }" @click="go('firmwares')"><svg><use href="#i-download"/></svg><span>固件管理</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('system') }" @click="go('system')"><svg><use href="#i-settings"/></svg><span>系统管理</span></button>
</nav>
<div class="sidebar-bottom">
<div class="profile-wrap">
<button class="sidebar-profile" @click="profileOpen = !profileOpen" :aria-expanded="String(profileOpen)">
<span>{{ userName.charAt(0) }}</span>
<div><strong>{{ userName }}</strong><small>嘉谷科技</small></div>
<svg><use href="#i-chevron"/></svg>
</button>
<div class="profile-menu" v-show="profileOpen">
<div class="profile-menu-heading"><strong>{{ userName }}</strong><small>{{ userEmail }}</small></div>
<button class="logout-action" @click="logout">退出登录</button>
</div>
</div>
</div>
</aside>
<main class="workspace">
<button class="icon-button mobile-menu" title="打开导航" @click="sidebarOpen = !sidebarOpen"><svg><use href="#i-menu"/></svg></button>
<router-view />
</main>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../store/user'
import { useUiStore } from '../store/ui'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const ui = useUiStore()
const sidebarOpen = ref(false)
const profileOpen = ref(false)
const taskOpen = ref(false)
const mediaOpen = ref(false)
const isAdmin = computed(() => userStore.isAdmin())
const userName = computed(() => userStore.user?.name || userStore.user?.phone || '平台管理员')
const userEmail = computed(() => userStore.user?.email || 'admin@jiagu-tech.cn')
const taskView = computed(() => route.query.view || 'plans')
const mediaView = computed(() => route.query.view || 'videos')
watch(
() => route.name,
(name) => {
taskOpen.value = name === 'tasks' || name === 'replay'
mediaOpen.value = name === 'media'
sidebarOpen.value = false
profileOpen.value = false
},
{ immediate: true }
)
function isActive(name) {
return route.name === name
}
function go(name, query) {
router.push({ name, query })
}
function toggleTask() {
if (route.name === 'tasks') {
taskOpen.value = !taskOpen.value
} else {
router.push({ name: 'tasks' })
}
}
function toggleMedia() {
if (route.name === 'media') {
mediaOpen.value = !mediaOpen.value
} else {
router.push({ name: 'media' })
}
}
async function logout() {
const ok = await ui.confirm('确认退出当前账号?', '退出后需要重新登录才能访问平台。', ['退出当前会话', '保留登录状态', '清除本地凭证'])
if (ok) {
userStore.logout()
router.push('/login')
ui.toast('已退出登录')
}
}
</script>

10
src/main.js

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/prototype.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

56
src/router/index.js

@ -0,0 +1,56 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../store/user'
import AppShell from '../layout/AppShell.vue'
const routes = [
{ path: '/login', name: 'login', component: () => import('../views/Login.vue') },
{
path: '/',
component: AppShell,
redirect: '/monitor',
children: [
{ path: 'monitor', name: 'monitor', component: () => import('../views/Monitor.vue') },
{ path: 'devices', name: 'devices', component: () => import('../views/Devices.vue') },
{ path: 'tasks', name: 'tasks', component: () => import('../views/Tasks.vue') },
{ path: 'replay/:key', name: 'replay', component: () => import('../views/Replay.vue') },
{ path: 'media', name: 'media', component: () => import('../views/Media.vue') },
{ path: 'account', name: 'account', component: () => import('../views/Account.vue') },
{ path: 'alarms', name: 'alarms', component: () => import('../views/Alarms.vue') },
{ path: 'system', name: 'system', component: () => import('../views/System.vue'), meta: { admin: true } },
{ path: 'users', name: 'users', component: () => import('../views/Users.vue'), meta: { admin: true } },
{ path: 'firmwares', name: 'firmwares', component: () => import('../views/Firmwares.vue'), meta: { admin: true } },
{ path: 'control/:id?', name: 'control', component: () => import('../views/Control.vue'), meta: { userOnly: true } },
{ path: 'logs', name: 'logs', component: () => import('../views/OperationLogs.vue'), meta: { userOnly: true } },
{ path: 'device/:id', name: 'device-detail', component: () => import('../views/DeviceDetail.vue') }
]
},
{ path: '/:pathMatch(.*)*', redirect: '/monitor' }
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.path === '/login') {
next()
return
}
if (!userStore.token) {
next('/login')
return
}
if (to.meta.admin && !userStore.isAdmin()) {
next('/monitor')
return
}
if (to.meta.userOnly && userStore.isAdmin()) {
next('/monitor')
return
}
next()
})
export default router

313
src/store/devices.js

@ -0,0 +1,313 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import request from '../api/request'
// 后端 status 值 → 展示文案 + 状态样式
const DOCK_STATUS = {
online: { status: '在线', statusClass: 'online' },
offline: { status: '离线', statusClass: 'offline' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
function gpsText(q) {
const key = String(q || '').toUpperCase().replace(/-/g, '_')
switch (key) {
case 'RTK_FIX':
case 'RTK_FIXED': return 'RTK 固定解'
case 'RTK_FLOAT': return 'RTK 浮点解'
case '3D_FIX': return '3D 定位'
case '2D_FIX': return '2D 定位'
default: return q ? String(q) : '无定位'
}
}
function modeText(m) {
const key = String(m || '').toUpperCase()
switch (key) {
case 'STANDBY': return '待命'
case 'AUTO': return 'AUTO'
case 'GUIDED': return 'GUIDED'
case 'LOITER': return '悬停'
case 'RTL': return '返航'
default: return m || '--'
}
}
function controlModeText(m) {
const key = String(m || '').toLowerCase()
if (key === 'auto' || key === 'automatic') return '自动'
if (key === 'manual') return '手动'
return m || '--'
}
function parseJson(value, fallback = null) {
if (!value) return fallback
if (typeof value === 'object') return value
try { return JSON.parse(value) } catch { return fallback }
}
function environmentValues(realtime) {
const nested = parseJson(realtime?.environment, {}) || {}
const value = (key, ...aliases) => {
for (const name of [key, ...aliases]) {
if (nested[name] != null && nested[name] !== '') return nested[name]
if (realtime?.[name] != null && realtime[name] !== '') return realtime[name]
}
return null
}
return {
rain: value('rain'),
windSpeed: value('windSpeed', 'wind_speed'),
outsideTemperature: value('outsideTemperature', 'outside_temperature'),
outsideHumidity: value('outsideHumidity', 'outside_humidity'),
insideTemperature: value('insideTemperature', 'inside_temperature', 'temperature'),
insideHumidity: value('insideHumidity', 'inside_humidity', 'humidity')
}
}
function alarmCodes(realtime) {
const codes = parseJson(realtime?.alarmCodes, [])
return Array.isArray(codes) ? codes : []
}
function displayValue(value) {
return value == null || value === '' ? '--' : String(value)
}
function coordinate(value, min, max) {
const number = Number(value)
return Number.isFinite(number) && number >= min && number <= max ? number : null
}
// 指令标题 → 后端指令 type
const COMMANDS = {
'一键起飞': 'workflow.one_key_takeoff',
'一键降落': 'workflow.one_key_land',
'起飞准备': 'dock.prepare_takeoff',
'起飞准备完成': 'dock.complete_takeoff',
'降落准备': 'dock.prepare_landing',
'降落准备完成': 'dock.complete_landing',
'打开舱门': 'dock.open',
'关闭舱门': 'dock.close',
'开始充电': 'dock.charge_start',
'整机复位': 'dock.reset',
'机巢复位': 'dock.reset',
'无人机开机': 'dock.drone_power_on',
'无人机关机': 'dock.drone_power_off',
'远程重启': 'dock.reboot',
'清除设备告警': 'dock.clear_alarm',
'设备自检': 'dock.self_check',
'起飞': 'drone.takeoff',
'降落': 'drone.land',
'一键返航': 'drone.return',
'悬停': 'drone.hover',
'紧急停止': 'drone.emergency_stop',
'开始任务': 'drone.mission_start',
'暂停任务': 'drone.mission_pause',
'继续任务': 'drone.mission_resume',
'取消任务': 'drone.mission_cancel'
}
// 经纬度 → 地图百分比坐标(按设备经纬范围动态投影,北向上)
function project(lon, lat, bounds) {
const lonSpan = bounds.maxLon - bounds.minLon || 1
const latSpan = bounds.maxLat - bounds.minLat || 1
const x = ((lon - bounds.minLon) / lonSpan) * 80 + 10
const y = ((bounds.maxLat - lat) / latSpan) * 70 + 15
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
export const useDeviceStore = defineStore('devices', () => {
const assets = reactive({})
const docks = ref([])
const drones = ref([])
const loaded = ref(false)
function asset(id) {
return assets[id] || null
}
function record(id) {
const key = String(id)
return docks.value.find((d) => d.id === key) || drones.value.find((d) => d.id === key) || null
}
function commandType(title) {
return COMMANDS[title] || ''
}
async function sendCommand(rec, title, params = {}) {
const type = COMMANDS[title]
if (!type) throw new Error('未知指令')
if (rec.kind === 'dock') {
return request.post(`/v1/docks/${rec.id}/command`, { type, params })
}
const dock = docks.value.find((d) => d.dockId === rec.dockId)
if (!dock) throw new Error('未找到绑定机巢')
return request.post(`/v1/docks/${dock.id}/command`, { type, params, droneSn: rec.droneSn })
}
async function load() {
const [dockPage, dronePage] = await Promise.all([
request.get('/v1/docks', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/drones', { params: { pageNum: 1, pageSize: 100 } })
])
const dockList = dockPage?.records || []
const droneList = dronePage?.records || []
const [dockDetails, droneDetails] = await Promise.all([
Promise.all(dockList.map((d) => request.get(`/v1/docks/${d.id}`).then((r) => r || {}).catch(() => ({})))),
Promise.all(droneList.map((d) => request.get(`/v1/drones/${d.id}`).then((r) => r || {}).catch(() => ({}))))
])
const coords = dockList
.map((d) => ({ longitude: coordinate(d.longitude, -180, 180), latitude: coordinate(d.latitude, -90, 90) }))
.filter((d) => d.longitude != null && d.latitude != null)
const defaultLon = coords[0]?.longitude ?? 0
const defaultLat = coords[0]?.latitude ?? 0
const bounds = {
minLon: Math.min(...coords.map((d) => Number(d.longitude)), defaultLon),
maxLon: Math.max(...coords.map((d) => Number(d.longitude)), defaultLon),
minLat: Math.min(...coords.map((d) => Number(d.latitude)), defaultLat),
maxLat: Math.max(...coords.map((d) => Number(d.latitude)), defaultLat)
}
if (bounds.minLon === bounds.maxLon) { bounds.minLon -= 0.01; bounds.maxLon += 0.01 }
if (bounds.minLat === bounds.maxLat) { bounds.minLat -= 0.01; bounds.maxLat += 0.01 }
const newDocks = dockList.map((d, i) => {
const id = String(d.id)
const detail = dockDetails[i] || {}
const rt = detail.realtime || {}
const online = detail.online ?? d.status === 'online'
const registerStatus = d.registerStatus || 'registered'
const pending = registerStatus === 'pending' && !online
const st = pending
? { status: '待连接', statusClass: 'pending' }
: online
? alarmCodes(rt).length ? { status: '告警', statusClass: 'alarm' } : DOCK_STATUS.online
: DOCK_STATUS.offline
const name = d.name || d.dockId
const environment = environmentValues(rt)
return {
id,
kind: 'dock',
dockId: d.dockId,
name,
code: d.code || d.sn || d.dockId,
sn: d.sn || d.dockId || '--',
iccid: d.iccid || '--',
longitude: coordinate(d.longitude, -180, 180),
latitude: coordinate(d.latitude, -90, 90),
location: d.location || '--',
status: st.status,
statusClass: st.statusClass,
binding: '--',
bindingState: 'unbound',
mode: controlModeText(rt.controlMode),
version: d.softwareVer ? `v${d.softwareVer}` : '--',
updated: fmtTime(d.updatedAt),
protocol: d.protocolVer ? `MQTT v${d.protocolVer}` : '--',
registerStatus,
realtime: rt,
environment,
alarms: alarmCodes(rt),
online
}
})
const newDrones = droneList.map((d, i) => {
const id = String(d.id)
const detail = droneDetails[i] || {}
const rt = detail.realtime || {}
const armed = ['1', 'true', 1, true].includes(rt.armed)
const altitude = coordinate(rt.altitude, -1000, 100000)
const flying = armed || (altitude != null && altitude > 0)
const online = detail.online ?? d.status !== 'offline'
const st = !online
? { status: '离线', statusClass: 'offline' }
: flying
? { status: '飞行中', statusClass: 'mission' }
: { status: '在巢', statusClass: 'online' }
const dock = newDocks.find((dk) => dk.dockId === d.dockId)
const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery
return {
id,
kind: 'drone',
droneSn: d.droneSn,
dockId: d.dockId,
name: d.name || d.droneSn,
code: d.code || d.droneSn,
status: st.status,
statusClass: st.statusClass,
binding: dock?.name || '--',
bindingState: dock ? 'bound' : 'unbound',
battery: battery != null && battery !== '' ? `${battery}%` : '--',
mode: modeText(rt.flightMode),
mission: displayValue(rt.missionName || rt.mission),
updated: fmtTime(d.updatedAt),
sysid: displayValue(rt.currentSysId),
gps: gpsText(rt.gpsQuality),
sn: d.droneSn || d.code || '--',
model: d.model || '--',
firmwareVer: d.firmwareVer || '--',
localRoutes: [],
realtime: rt,
alarms: alarmCodes(rt),
online,
_lat: coordinate(rt.latitude, -90, 90),
_lon: coordinate(rt.longitude, -180, 180),
_alt: altitude,
_speed: coordinate(rt.groundSpeed, 0, 1000),
_sats: coordinate(rt.satellites, 0, 1000)
}
})
for (const dk of newDocks) {
const dr = newDrones.find((d) => d.dockId === dk.dockId)
if (dr) {
dk.binding = dr.name
dk.bindingState = 'bound'
}
}
docks.value = newDocks
drones.value = newDrones
// 重建地图资产
Object.keys(assets).forEach((k) => delete assets[k])
for (const dk of newDocks) {
const hasCoordinates = dk.longitude != null && dk.latitude != null
const p = hasCoordinates ? project(dk.longitude, dk.latitude, bounds) : { x: null, y: null }
assets[dk.id] = {
id: dk.id, type: 'dock', name: dk.name, location: dk.location,
status: dk.status, statusClass: dk.statusClass, x: p.x, y: p.y, hasCoordinates,
realtime: dk.realtime, environment: dk.environment, alarms: dk.alarms, online: dk.online
}
}
for (const dr of newDrones) {
const dock = newDocks.find((d) => d.dockId === dr.dockId)
const lon = dr._lon != null ? dr._lon : dock?.longitude
const lat = dr._lat != null ? dr._lat : dock?.latitude
const hasCoordinates = lon != null && lat != null
const p = hasCoordinates ? project(lon, lat, bounds) : { x: null, y: null }
assets[dr.id] = {
id: dr.id, type: 'drone', name: dr.name,
location: dr.statusClass === 'mission' ? `${dock?.location || '--'} · 飞行中` : `停放于${dock?.name || '--'}`,
status: dr.status, statusClass: dr.statusClass, x: p.x, y: p.y, hasCoordinates,
parent: dock?.id || '', parentName: dock?.name || '--',
inDock: dr.statusClass !== 'mission', altitude: dr._alt, speed: dr._speed, satellites: dr._sats,
realtime: dr.realtime, alarms: dr.alarms, online: dr.online
}
}
loaded.value = true
return { docks: newDocks, drones: newDrones }
}
return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load }
})

33
src/store/ui.js

@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const toastMsg = ref('')
const toastVisible = ref(false)
let toastTimer = null
function toast(message) {
toastMsg.value = message
toastVisible.value = true
clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastVisible.value = false }, 2200)
}
const confirmVisible = ref(false)
const confirmTitle = ref('')
const confirmDesc = ref('')
const confirmChecks = ref([])
let confirmResolve = null
function confirm(title, desc = '系统将校验设备状态和流程互斥条件,确认通过后提交本次操作。', checks = ['设备在线', '无活动告警', '指令互斥检查']) {
confirmTitle.value = title
confirmDesc.value = desc
confirmChecks.value = checks
confirmVisible.value = true
return new Promise((resolve) => { confirmResolve = resolve })
}
function resolveConfirm(ok) {
confirmVisible.value = false
if (confirmResolve) { confirmResolve(ok); confirmResolve = null }
}
return { toastMsg, toastVisible, toast, confirmVisible, confirmTitle, confirmDesc, confirmChecks, confirm, resolveConfirm }
})

27
src/store/user.js

@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
function setLogin(tokenVal, userVal) {
token.value = tokenVal
user.value = userVal
localStorage.setItem('token', tokenVal)
localStorage.setItem('user', JSON.stringify(userVal))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
function isAdmin() {
return !!user.value && user.value.role === 'admin'
}
return { token, user, setLogin, logout, isAdmin }
})

893
src/styles/prototype.css

File diff suppressed because one or more lines are too long

413
src/views/Account.vue

@ -0,0 +1,413 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>账户中心</h1>
<span>管理云媒体流量通信卡订单与账号安全</span>
</div>
</header>
<div class="business-metric-band account-metrics">
<div><span>企业账户</span><strong>{{ tenantName }}</strong><small>状态正常</small></div>
<div><span>云媒体流量</span><strong>{{ balance }}</strong><small>GB</small></div>
<div><span>通信卡</span><strong>{{ simCards.length }}</strong><small></small></div>
<div><span>接入设备</span><strong>{{ deviceCount }}</strong><small>机巢 + 无人机</small></div>
</div>
<div class="business-workspace">
<div class="business-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</button>
<button :class="{ active: tab === 'traffic' }" @click="tab = 'traffic'">云媒体流量</button>
<button :class="{ active: tab === 'sim' }" @click="tab = 'sim'">通信卡</button>
<button :class="{ active: tab === 'security' }" @click="tab = 'security'">账号安全</button>
</div>
<!-- 概览 -->
<div class="business-panel account-panel" :class="{ active: tab === 'overview' }">
<div class="account-overview-grid">
<section class="account-info-block">
<div class="account-section-heading"><h2>企业信息</h2><span>入驻信息</span></div>
<div class="tenant-identity">
<span>{{ tenantInitial }}</span>
<div><strong>{{ tenantName }}</strong><small>企业账号 · {{ tenantRole }}</small></div>
<button class="table-text-action" @click="ui.toast('编辑企业信息')">编辑</button>
</div>
<dl class="account-details">
<div><dt>联系人</dt><dd>{{ profileName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ profilePhone }}</dd></div>
<div><dt>企业邮箱</dt><dd>{{ profileEmail }}</dd></div>
<div><dt>入驻时间</dt><dd>{{ createdAt }}</dd></div>
</dl>
</section>
<section class="account-info-block">
<div class="account-section-heading"><h2>资源概览</h2><span>云媒体流量 · 通信卡</span></div>
<div class="resource-progress">
<div><span>云媒体流量 <b>{{ balance }} GB</b></span><i><em style="width:42%"></em></i></div>
<div><span>通信卡 <b>{{ activeSimCount }} 张正常</b></span><i><em :style="{ width: simUsagePct + '%' }"></em></i></div>
<div><span>设备接入 <b>{{ deviceCount }} </b></span><i><em style="width:35%"></em></i></div>
</div>
<div class="account-card-actions">
<button class="primary-command" @click="tab = 'traffic'">购买流量</button>
<button class="plain-command" @click="tab = 'sim'">管理通信卡</button>
</div>
</section>
</div>
</div>
<!-- 云媒体流量 -->
<div class="business-panel account-panel" :class="{ active: tab === 'traffic' }" style="padding:18px">
<div class="traffic-balance-card">
<span>当前云媒体流量余额</span>
<strong>{{ balance }} <small>GB</small></strong>
<p>流量用于直播推流视频回放与原始素材下载</p>
</div>
<div class="traffic-purchase-layout" style="margin-top:16px">
<div class="traffic-amount-block">
<label><span>充值流量GB</span><div><input v-model="trafficAmount" type="number" min="1" /><b>GB</b></div></label>
<div class="traffic-quick-amounts">
<button v-for="a in [10, 50, 100, 500]" :key="a" :class="{ active: trafficAmount == a }" @click="trafficAmount = a">{{ a }} GB</button>
</div>
</div>
<div class="traffic-order-summary">
<span>订单概要</span>
<strong>云媒体流量充值</strong>
<small>单价</small>
<b>¥ {{ (trafficAmount * 0.8).toFixed(2) }}</b>
<small>含税充值后立即到账</small>
<button class="primary-command" @click="createOrder">立即下单</button>
</div>
</div>
<div class="account-subsection-title"><h3>订单记录</h3><span>流量充值订单</span></div>
<div class="account-order-table">
<div class="order-head"><span>订单号</span><span>类型</span><span>内容</span><span>金额</span><span>状态</span><span>时间</span></div>
<div v-for="o in orders" :key="o.id"><span>{{ o.id }}</span><strong>{{ o.type }}</strong><span>{{ o.desc }}</span><strong>¥{{ o.amount }}</strong><em :class="{ processing: o._processing }">{{ o.status }}</em><span>{{ o.time }}</span></div>
</div>
<div class="account-subsection-title"><h3>消费明细</h3><span>直播 / 回放 / 下载</span></div>
<div class="account-order-table usage-table">
<div class="order-head"><span>来源</span><span>对象 ID</span><span>用量</span><span>扣减前</span><span>扣减后</span><span>时间</span></div>
<div v-for="u in usage" :key="u.id"><span>{{ u.source }}</span><span>{{ u.sourceId }}</span><strong>{{ u.bytes }}</strong><span>{{ u.before }}</span><span>{{ u.after }}</span><span>{{ u.time }}</span></div>
</div>
</div>
<!-- 通信卡 -->
<div class="business-panel account-panel" :class="{ active: tab === 'sim' }" style="padding:16px">
<div class="communication-status-grid">
<div><strong>{{ simCards.length }}</strong><span>通信卡总数</span></div>
<div><strong>{{ activeSimCount }}</strong><span>正常</span></div>
<div><strong>{{ warnSimCount }}</strong><span>异常</span></div>
</div>
<div class="communication-table-wrap">
<table class="communication-table">
<thead>
<tr>
<th>运营商</th>
<th>ICCID / 号码</th>
<th>绑定机巢</th>
<th>套餐用量</th>
<th>状态</th>
<th>到期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="c in simCards" :key="c.id">
<td><strong>{{ c.carrier }}</strong></td>
<td><strong>{{ c.iccid }}</strong><small>{{ c.phone }}</small></td>
<td>{{ c.dockId }}</td>
<td>
<div class="communication-usage" :class="{ warning: c.usagePct > 80, danger: c.usagePct > 95 }">
<span>{{ c.usedGb }} / {{ c.planGb }} GB</span>
<i><em :style="{ width: c.usagePct + '%' }"></em></i>
</div>
</td>
<td>
<span class="carrier-status" :class="c.statusClass"><i></i>{{ c.carrierStatusName }}</span>
</td>
<td>{{ c.expiredAt }}</td>
<td><button @click="openRecharge(c)">充值</button></td>
</tr>
</tbody>
</table>
<div v-if="!simCards.length" class="table-empty">暂无通信卡</div>
</div>
<div class="account-subsection-title"><h3>通信卡充值记录</h3><span>本账户 SIM 卡套餐充值</span></div>
<div class="asset-toolbar sim-recharge-toolbar">
<div class="toolbar-group">
<select v-model="simRechargeCardFilter" @change="reloadSimRechargeLogs">
<option value="">全部通信卡</option>
<option v-for="c in simCards" :key="c.id" :value="c.id">{{ c.carrier }} · {{ c.iccid }}</option>
</select>
</div>
</div>
<div class="account-order-table sim-recharge-table">
<div class="order-head"><span>通信卡</span><span>充值流量</span><span>金额</span><span>支付状态</span><span>充值状态</span><span>时间</span></div>
<div v-for="r in simRechargeLogs" :key="r.id">
<span>{{ rechargeCardLabel(r.simCardId) }}</span>
<strong>{{ r.amountGb }} GB</strong>
<strong>¥{{ Number(r.totalPrice ?? 0).toFixed(2) }}</strong>
<em :class="{ processing: r.payStatus !== 'paid' }">{{ payStatusName(r.payStatus) }}</em>
<em :class="{ processing: r.rechargeStatus !== 'success' }">{{ rechargeStatusName(r.rechargeStatus) }}</em>
<span>{{ fmtTime(r.createdAt) }}</span>
</div>
</div>
<div v-if="!simRechargeLogs.length" class="table-empty">暂无通信卡充值记录</div>
<div v-if="simRechargeLogs.length" class="table-footer">
<span> {{ simRechargeTotal }} 条记录 · {{ simRechargePageNum }} / {{ simRechargePages }} </span>
<div>
<button @click="goSimRechargePage(simRechargePageNum - 1)"><svg><use href="#i-chevron" /></svg></button>
<b>{{ simRechargePageNum }}</b>
<button @click="goSimRechargePage(simRechargePageNum + 1)"><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
<!-- 账号安全 -->
<div class="business-panel account-panel" :class="{ active: tab === 'security' }" style="padding:18px">
<div class="account-section-heading"><h2>账号安全</h2><span>保护你的登录凭证</span></div>
<div class="security-list">
<button @click="ui.toast('修改密码')"><span><strong>登录密码</strong><small>定期更新可提升账号安全性</small></span><b>修改</b></button>
<button @click="ui.toast('绑定手机号')"><span><strong>绑定手机号</strong><small>{{ maskPhone(profilePhone) }}</small></span><b>更换</b></button>
<button @click="ui.toast('绑定邮箱')"><span><strong>绑定邮箱</strong><small>{{ profileEmail }}</small></span><b>更换</b></button>
</div>
</div>
</div>
<!-- 通信卡充值弹窗 -->
<div class="modal-backdrop" :class="{ open: rechargeOpen }" @click.self="rechargeOpen = false">
<div class="recharge-modal">
<h3>通信卡充值</h3>
<p class="recharge-sub" v-if="rechargeCard">{{ rechargeCard.carrier }} · {{ rechargeCard.iccid }} · 当前套餐 {{ rechargeCard.planGb }} GB</p>
<div class="carrier-package-grid">
<button v-for="a in [10, 50, 100, 500]" :key="a" :class="{ active: rechargeAmount === a }" @click="rechargeAmount = a">
<strong>{{ a }} GB</strong><small>流量包</small>
</button>
</div>
<div class="carrier-recharge-total">
<span>合计</span>
<strong>¥ {{ (rechargeAmount * 0.8).toFixed(2) }}</strong>
</div>
<div class="recharge-modal-footer">
<button class="cancel" @click="rechargeOpen = false">取消</button>
<button class="confirm" :disabled="recharging" @click="submitRecharge">{{ recharging ? '充值中' : '确认充值' }}</button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useUserStore } from '../store/user'
import { useUiStore } from '../store/ui'
import request from '../api/request'
import { useDeviceStore } from '../store/devices'
const userStore = useUserStore()
const ui = useUiStore()
const deviceStore = useDeviceStore()
const tab = ref('overview')
const trafficAmount = ref(50)
const tenantName = computed(() => userStore.user?.name || '嘉谷科技')
const profileName = computed(() => userStore.user?.name || '平台管理员')
const profilePhone = computed(() => userStore.user?.phone || '--')
const profileEmail = computed(() => userStore.user?.email || '--')
const tenantRole = computed(() => (userStore.isAdmin() ? '管理员' : '成员'))
const createdAt = computed(() => (userStore.user?.createdAt ? String(userStore.user.createdAt).slice(0, 10) : '--'))
const tenantInitial = computed(() => tenantName.value.charAt(0))
const balance = ref('--')
const orders = ref([])
const usage = ref([])
const simCards = ref([])
const simRechargeLogs = ref([])
const simRechargeTotal = ref(0)
const simRechargePages = ref(1)
const simRechargePageNum = ref(1)
const simRechargeCardFilter = ref('')
const deviceCount = ref(0)
const activeSimCount = computed(() => simCards.value.filter((c) => c.statusClass === 'normal').length)
const warnSimCount = computed(() => simCards.value.length - activeSimCount.value)
const simUsagePct = computed(() => (simCards.value.length ? Math.round((activeSimCount.value / simCards.value.length) * 100) : 0))
const rechargeOpen = ref(false)
const rechargeCard = ref(null)
const rechargeAmount = ref(50)
const recharging = ref(false)
const PAY_STATUS = {
unpaid: { status: '待支付', processing: true },
paid: { status: '已完成', processing: false }
}
const SOURCE_NAME = { live: '直播', replay: '回放', download: '下载' }
const CARRIER_STATUS = {
normal: { name: '正常', cls: 'normal' },
suspended: { name: '停机', cls: 'danger' },
arrears: { name: '欠费', cls: 'danger' },
cancelled: { name: '已注销', cls: 'owned' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
function fmtGb(bytes) {
if (bytes == null) return '--'
return (Number(bytes) / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
async function load() {
try {
await deviceStore.load()
deviceCount.value = deviceStore.docks.length + deviceStore.drones.length
const simRechargeParams = { pageNum: simRechargePageNum.value, pageSize: 10 }
if (simRechargeCardFilter.value) simRechargeParams.simCardId = simRechargeCardFilter.value
const [bal, orderPage, usagePage, simPage, simRechargePage] = await Promise.all([
request.get('/v1/account/traffic/balance'),
request.get('/v1/account/traffic/orders', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/traffic/usage', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/sim-cards', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/sim-recharge-logs', { params: simRechargeParams })
])
if (bal?.balanceGb != null) balance.value = Number(bal.balanceGb).toFixed(1)
else balance.value = Number(userStore.user?.trafficBalance ?? 0).toFixed(1)
orders.value = (orderPage?.records || []).map((o) => {
const st = PAY_STATUS[o.payStatus] || { status: o.payStatus, processing: false }
return {
id: String(o.id),
type: '云媒体流量',
desc: `${o.amountGb} GB 流量`,
amount: Number(o.totalPrice ?? 0).toFixed(2),
status: st.status,
_processing: st.processing,
time: fmtTime(o.createdAt)
}
})
usage.value = (usagePage?.records || []).map((u) => ({
id: String(u.id),
source: SOURCE_NAME[u.sourceType] || u.sourceType || '--',
sourceId: String(u.sourceId ?? '--'),
bytes: fmtGb(u.bytesUsed),
before: fmtGb(u.balanceBefore),
after: fmtGb(u.balanceAfter),
time: fmtTime(u.createdAt)
}))
simCards.value = (simPage?.records || []).map((c) => {
const planGb = Number(c.planGb ?? 0)
const usedGb = Number(c.usedGb ?? 0)
const usagePct = planGb > 0 ? Math.min((usedGb / planGb) * 100, 100) : 0
const st = CARRIER_STATUS[c.carrierStatus] || { name: c.carrierStatus || '--', cls: 'normal' }
return {
id: String(c.id),
carrier: c.carrier || '--',
iccid: c.iccid || '--',
phone: c.phone || '--',
dockId: c.dockId || '--',
planGb,
usedGb: usedGb.toFixed(1),
usagePct,
statusClass: st.cls,
carrierStatusName: st.name,
expiredAt: c.expiredAt ? String(c.expiredAt).slice(0, 10) : '--'
}
})
simRechargeTotal.value = simRechargePage?.total || 0
simRechargePages.value = simRechargePage?.pages || 1
simRechargeLogs.value = simRechargePage?.records || []
} catch (e) {
ui.toast(e.message || '加载账户数据失败')
}
}
onMounted(load)
function payStatusName(status) {
return PAY_STATUS[status]?.status || status || '--'
}
function rechargeStatusName(status) {
return { pending: '处理中', success: '充值成功', failed: '充值失败' }[status] || status || '--'
}
function rechargeCardLabel(id) {
const card = simCards.value.find((c) => c.id === String(id))
return card ? `${card.carrier} · ${card.iccid}` : String(id || '--')
}
async function reloadSimRechargeLogs() {
simRechargePageNum.value = 1
await loadSimRechargeLogs()
}
async function loadSimRechargeLogs() {
try {
const params = { pageNum: simRechargePageNum.value, pageSize: 10 }
if (simRechargeCardFilter.value) params.simCardId = simRechargeCardFilter.value
const page = await request.get('/v1/account/sim-recharge-logs', { params })
simRechargeTotal.value = page?.total || 0
simRechargePages.value = page?.pages || 1
simRechargeLogs.value = page?.records || []
} catch (e) {
ui.toast(e.message || '加载通信卡充值记录失败')
}
}
function goSimRechargePage(page) {
if (page < 1 || page > simRechargePages.value) return
simRechargePageNum.value = page
loadSimRechargeLogs()
}
function maskPhone(p) {
if (!p) return ''
const s = String(p)
return s.length > 7 ? s.slice(0, 3) + '****' + s.slice(-4) : s
}
async function createOrder() {
try {
await request.post('/v1/account/traffic/orders', { amountGb: Number(trafficAmount.value) || 1 })
ui.toast('订单已创建,请联系管理员完成支付')
await load()
} catch (e) {
ui.toast(e.message || '下单失败')
}
}
function openRecharge(card) {
rechargeCard.value = card
rechargeAmount.value = 50
rechargeOpen.value = true
}
async function submitRecharge() {
if (!rechargeCard.value) return
recharging.value = true
try {
await request.post(`/v1/account/sim-cards/${rechargeCard.value.id}/recharge`, { amountGb: Number(rechargeAmount.value) || 1 })
ui.toast('充值成功')
rechargeOpen.value = false
await load()
await loadSimRechargeLogs()
} catch (e) {
ui.toast(e.message || '充值失败')
} finally {
recharging.value = false
}
}
</script>

136
src/views/Alarms.vue

@ -0,0 +1,136 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>告警运维</h1>
<span>集中处理设备告警与异常事件</span>
</div>
<button class="primary-command" @click="ui.toast('导出告警记录')"><svg><use href="#i-download" /></svg>导出记录</button>
</header>
<div class="business-metric-band alarm-metrics">
<div><span>待处理告警</span><strong>{{ countActive }}</strong><small class="orange-text">需关注</small></div>
<div><span>严重告警</span><strong>{{ countCritical }}</strong><small class="red-text">立即处理</small></div>
<div><span>今日新增</span><strong>{{ countToday }}</strong><small>实时统计</small></div>
<div><span>已恢复</span><strong>{{ countResolved }}</strong><small class="green-text">累计</small></div>
</div>
<div class="business-workspace alarm-workspace">
<div class="alarm-layout" style="height:100%">
<div class="alarm-list">
<article v-for="a in alarms" :key="a.id" class="alarm-item" :class="a.severityClass">
<span class="alarm-severity">{{ a.severity }}</span>
<div>
<strong>{{ a.title }}</strong>
<small>{{ a.device }} · {{ a.time }}</small>
</div>
<em class="status" :class="a.statusClass">{{ a.status }}</em>
<button class="alarm-handle" @click="handle(a)">处理</button>
</article>
<div v-if="!alarms.length" class="table-empty">暂无告警</div>
</div>
<div class="alarm-side-panel">
<section>
<h2>告警级别分布</h2>
<div class="alarm-bars">
<div><span>严重</span><b><i :style="barStyle(countCritical)"></i></b><strong>{{ countCritical }}</strong></div>
<div><span>警告</span><b><i :style="barStyle(countWarning)"></i></b><strong>{{ countWarning }}</strong></div>
<div><span>提示</span><b><i :style="barStyle(countInfo)"></i></b><strong>{{ countInfo }}</strong></div>
</div>
</section>
<section>
<h2>运维提示</h2>
<p class="alarm-tip"><svg><use href="#i-alert" /></svg>严重告警建议在 5 分钟内响应避免设备持续异常运行</p>
<p class="alarm-tip"><svg><use href="#i-zap" /></svg>离线设备请先检查通信链路与供电状态</p>
</section>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const ui = useUiStore()
const LEVEL = {
critical: { severity: '严重', severityClass: 'critical' },
warning: { severity: '警告', severityClass: 'medium' },
info: { severity: '提示', severityClass: 'low' }
}
const STATUS = {
active: { status: '待处理', statusClass: 'alarm' },
acknowledged: { status: '已确认', statusClass: 'pending' },
resolved: { status: '已恢复', statusClass: 'online' }
}
const alarms = ref([])
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
const page = await request.get('/v1/alarms', { params: { pageNum: 1, pageSize: 100 } })
alarms.value = (page?.records || []).map((a) => {
const lv = LEVEL[a.level] || LEVEL.warning
const st = STATUS[a.status] || STATUS.active
return {
id: String(a.id),
severity: lv.severity,
severityClass: lv.severityClass,
title: a.messageCn || a.code,
device: a.deviceType === 'drone' ? a.dockId : a.dockId,
time: fmtTime(a.triggeredAt),
status: st.status,
statusClass: st.statusClass,
level: a.level,
rawStatus: a.status
}
})
} catch (e) {
ui.toast(e.message || '加载告警失败')
}
}
onMounted(load)
const countActive = computed(() => alarms.value.filter((a) => a.rawStatus === 'active').length)
const countResolved = computed(() => alarms.value.filter((a) => a.rawStatus === 'resolved').length)
const countCritical = computed(() => alarms.value.filter((a) => a.level === 'critical').length)
const countWarning = computed(() => alarms.value.filter((a) => a.level === 'warning').length)
const countInfo = computed(() => alarms.value.filter((a) => a.level === 'info').length)
const countToday = computed(() => {
const today = new Date().toDateString()
return alarms.value.filter((a) => {
const d = new Date(a.time)
return !Number.isNaN(d.getTime()) && d.toDateString() === today
}).length
})
function barStyle(n) {
const max = Math.max(countCritical.value, countWarning.value, countInfo.value, 1)
return { '--value': `${Math.round((n / max) * 100)}%` }
}
async function handle(a) {
const ok = await ui.confirm('处理告警:' + a.title, '确认后将标记该告警为已处理并通知相关人员。', ['确认告警原因', '设备状态正常', '记录处理结果'])
if (!ok) return
try {
await request.put(`/v1/alarms/${a.id}/resolve`)
ui.toast('告警已处理')
await load()
} catch (e) {
ui.toast(e.message || '处理失败')
}
}
</script>

152
src/views/Control.vue

@ -0,0 +1,152 @@
<template>
<section class="control-page">
<header class="control-page-header">
<h1>远程控制</h1>
<span>对机巢与无人机下发远程指令</span>
</header>
<div class="control-workspace">
<aside class="control-device-list">
<div class="control-list-heading">
<div><h2>设备列表</h2><span>选择要控制的设备</span></div>
<button class="icon-button" @click="ui.toast('刷新设备')"><svg><use href="#i-refresh" /></svg></button>
</div>
<div class="control-device-items">
<button
v-for="d in controlDevices"
:key="d.id"
class="control-device-item"
:class="{ active: selectedId === d.id }"
@click="selectedId = d.id"
>
<span class="control-device-icon"><svg><use :href="d.kind === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
<div><strong>{{ d.name }}</strong><small>{{ d.code }} · {{ d.location || d.binding }}</small></div>
<em class="status" :class="d.statusClass">{{ d.status }}</em>
</button>
</div>
</aside>
<main class="control-main">
<template v-if="selected">
<header class="control-selected-header">
<div>
<h2>{{ selected.name }}</h2>
<p>{{ selected.kind === 'drone' ? '巡检无人机' : '机巢设备' }} · {{ selected.code }}</p>
</div>
<em class="status" :class="selected.statusClass">{{ selected.status }}</em>
</header>
<div class="control-status-band">
<template v-if="selected.kind === 'drone'">
<div><span>剩余电量</span><strong>{{ num(selected.battery) }}%</strong></div>
<div><span>飞行模式</span><strong>{{ selected.mode }}</strong></div>
<div><span>定位状态</span><strong>{{ selected.gps }}</strong></div>
<div><span>当前任务</span><strong>{{ selected.mission }}</strong></div>
<div><span>更新时间</span><strong>{{ selected.updated }}</strong></div>
</template>
<template v-else>
<div><span>绑定状态</span><strong>{{ selected.bindingState === 'bound' ? '已绑定' : '未绑定' }}</strong></div>
<div><span>绑定设备</span><strong>{{ selected.binding }}</strong></div>
<div><span>工作模式</span><strong>{{ selected.mode }}</strong></div>
<div><span>软件版本</span><strong>{{ selected.version }}</strong></div>
<div><span>通信协议</span><strong>{{ selected.protocol }}</strong></div>
</template>
</div>
<section class="control-alert-section">
<div class="control-alert-content" :class="selected.statusClass">
<span class="control-alert-icon" :class="{ ok: selected.statusClass === 'online' }"><svg><use href="#i-alert" /></svg></span>
<div>
<strong>{{ alertTitle }}</strong>
<small>{{ alertDesc }}</small>
</div>
<button class="plain-command" @click="ui.toast('查看详情')">详情</button>
</div>
</section>
<section class="control-section">
<div class="data-section-title"><h3 style="margin:0;font-size:12px">控制指令</h3><span>下发前将进行安全检查</span></div>
<div class="control-command-grid" v-if="selected.kind === 'drone'">
<button class="control-command primary" @click="run('起飞', '无人机将立即起飞进入待命状态。')"><svg><use href="#i-plane" /></svg><span><strong>起飞</strong><small>开始任务</small></span></button>
<button class="control-command landing" @click="run('降落', '无人机将就地降落并停止任务。')"><svg><use href="#i-plane" /></svg><span><strong>降落</strong><small>结束任务</small></span></button>
<button class="control-command" @click="run('一键返航', '无人机将返回绑定机巢。')"><b>R</b><span><strong>一键返航</strong><small>自动回巢</small></span></button>
<button class="control-command" @click="run('悬停', '无人机将在当前位置悬停。')"><b>H</b><span><strong>悬停</strong><small>保持位置</small></span></button>
<button class="control-command danger" @click="run('紧急停止', '立即中止飞行,无人机原地悬停待命。')"><b>E</b><span><strong>紧急停止</strong><small>中止任务</small></span></button>
</div>
<div class="control-command-grid" v-else>
<button class="control-command primary" @click="run('一键起飞', '无人机将从机巢起飞并进入待命状态。')"><svg><use href="#i-plane" /></svg><span><strong>一键起飞</strong><small>自动开舱</small></span></button>
<button class="control-command" @click="run('打开舱门', '将打开机巢舱门。')"><b>01</b><span><strong>打开舱门</strong><small>开合机构</small></span></button>
<button class="control-command" @click="run('关闭舱门', '将关闭机巢舱门。')"><b>02</b><span><strong>关闭舱门</strong><small>开合机构</small></span></button>
<button class="control-command" @click="run('机巢复位', '机巢将执行复位自检。')"><b>03</b><span><strong>机巢复位</strong><small>状态复位</small></span></button>
<button class="control-command" @click="run('设备自检', '对机巢各子系统执行自检。')"><b>04</b><span><strong>设备自检</strong><small>健康诊断</small></span></button>
</div>
</section>
<section class="control-section control-note">
<svg><use href="#i-shield" /></svg>
<span><strong>安全提示</strong><small>所有远程指令均会经过设备在线校验与指令互斥检查确认通过后才会下发执行</small></span>
</section>
</template>
<div v-else class="table-empty" style="height:100%;display:grid;place-items:center;color:var(--muted);font-size:12px">请在左侧选择设备</div>
</main>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useDeviceStore } from '../store/devices'
import { useUiStore } from '../store/ui'
const route = useRoute()
const devices = useDeviceStore()
const ui = useUiStore()
const controlDevices = computed(() => [...devices.drones, ...devices.docks])
const selectedId = ref(route.params.id || null)
const selected = computed(() => (selectedId.value ? devices.record(selectedId.value) : null))
onMounted(async () => {
try {
await devices.load()
if (!selectedId.value && devices.docks.length) selectedId.value = devices.docks[0].id
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
})
const alertTitle = computed(() => {
if (!selected.value) return ''
if (selected.value.statusClass === 'offline') return '设备离线,无法下发指令'
if (selected.value.statusClass === 'alarm') return '设备存在未处理告警'
if (selected.value.statusClass === 'mission') return '设备正在执行任务'
return '设备状态正常,可下发指令'
})
const alertDesc = computed(() => {
if (!selected.value) return ''
if (selected.value.statusClass === 'offline') return '请检查通信链路与供电状态'
if (selected.value.statusClass === 'alarm') return '建议先处理告警再执行控制操作'
if (selected.value.statusClass === 'mission') return '部分指令将在当前任务结束后生效'
return '所有子系统运行正常'
})
function num(v) {
if (v == null) return '--'
return String(v).replace('%', '')
}
async function run(title, desc) {
const rec = selected.value
if (!rec) return
const ok = await ui.confirm(title, desc)
if (!ok) return
try {
await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`)
} catch (e) {
ui.toast(e.message || '指令下发失败')
}
}
</script>

593
src/views/DeviceDetail.vue

@ -0,0 +1,593 @@
<template>
<section class="device-detail-page" :class="{ 'drone-detail': isDrone }">
<template v-if="record">
<header class="full-detail-header">
<button class="back-command" @click="router.push({ name: 'devices' })"><svg><use href="#i-chevron" /></svg>返回设备列表</button>
<div class="full-detail-identity">
<span :class="{ drone: isDrone }"><svg><use :href="isDrone ? '#i-plane' : '#i-home'" /></svg></span>
<div>
<small>{{ isDrone ? '无人机设备' : '机巢设备' }}</small>
<h1>{{ record.name }}</h1>
<p>{{ record.code }}</p>
</div>
<em class="status" :class="record.statusClass">{{ record.status }}</em>
</div>
<div class="full-detail-actions" v-if="!isAdmin">
<button v-if="!isDrone" class="plain-command" @click="editDockVisible = true">编辑信息</button>
<button class="plain-command" @click="goControl"><svg><use href="#i-zap" /></svg>远程控制</button>
<button class="primary-command" @click="goMonitor"><svg><use href="#i-radio" /></svg>实时监控</button>
</div>
</header>
<div class="full-detail-tabs">
<button :class="{ active: activeTab === 'overview' }" @click="activeTab = 'overview'">概览</button>
<button :class="{ active: activeTab === 'state' }" @click="activeTab = 'state'">设备状态</button>
<button :class="{ active: activeTab === 'alarms' }" @click="activeTab = 'alarms'">告警记录<b v-if="alarmRecords.length">{{ alarmRecords.length }}</b></button>
<button :class="{ active: activeTab === 'logs' }" @click="activeTab = 'logs'">操作记录</button>
<button :class="{ active: activeTab === 'diagnostics' }" @click="activeTab = 'diagnostics'">运维诊断</button>
</div>
<div v-if="activeTab === 'overview'" class="full-detail-content">
<template v-if="!isDrone">
<div class="detail-metric-band">
<div><span>控制模式</span><strong>{{ record.mode }}</strong></div>
<div><span>无人机在位</span><strong :class="{ 'green-text': isTrue(rt('dronePresent')) }">{{ booleanText(rt('dronePresent')) }}</strong></div>
<div><span>舱门状态</span><strong>{{ doorSummary }}</strong></div>
<div><span>充电状态</span><strong :class="{ 'green-text': chargeTone }">{{ chargeText(rt('chargingState')) }}</strong></div>
<div><span>当前告警</span><strong>{{ record.alarms?.length ?? 0 }}</strong></div>
<div><span>最近上报</span><strong>{{ record.updated }}</strong></div>
</div>
<div class="detail-columns">
<div class="detail-main-column">
<section class="data-section">
<div class="data-section-title"><h2>机巢运行状态</h2><button @click="activeTab = 'state'">全部状态</button></div>
<div class="status-matrix">
<div><svg><use href="#i-door" /></svg><span>左侧舱门</span><strong>{{ doorText('left') }}</strong><i v-if="isClosed(doorValue('left'))" class="ok"></i></div>
<div><svg><use href="#i-door" /></svg><span>右侧舱门</span><strong>{{ doorText('right') }}</strong><i v-if="isClosed(doorValue('right'))" class="ok"></i></div>
<div><svg><use href="#i-grid" /></svg><span>左右归中</span><strong>{{ centeringText('leftRight') }}</strong><i v-if="isTight(centeringValue('leftRight'))" class="ok"></i></div>
<div><svg><use href="#i-grid" /></svg><span>前后归中</span><strong>{{ centeringText('frontBack') }}</strong><i v-if="isTight(centeringValue('frontBack'))" class="ok"></i></div>
<div><svg><use href="#i-alert" /></svg><span>急停状态</span><strong>{{ isTrue(rt('emergencyStop')) ? '已触发' : booleanText(rt('emergencyStop')) === '--' ? '--' : '未触发' }}</strong><i v-if="isFalse(rt('emergencyStop'))" class="ok"></i></div>
<div><svg><use href="#i-refresh" /></svg><span>整机状态</span><strong>{{ record.status }}</strong><i v-if="record.online" class="ok"></i></div>
</div>
</section>
<section class="data-section">
<h2>充电系统</h2>
<div class="value-table">
<div><span>充电状态</span><strong :class="{ 'green-text': chargeTone }">{{ chargeText(rt('chargingState')) }}</strong></div>
<div><span>充电电压</span><strong>{{ withUnit(rt('chargingVoltage'), ' V') }}</strong></div>
<div><span>充电电流</span><strong>{{ withUnit(rt('chargingCurrent'), ' A') }}</strong></div>
<div><span>无人机电量</span><strong>{{ boundDrone?.battery || '--' }}</strong></div>
<div><span>无人机电源</span><strong>{{ booleanText(rt('dronePowerOnComplete')) }}</strong></div>
<div><span>充电启动反馈</span><strong>{{ completeText(rt('chargeStartComplete')) }}</strong></div>
</div>
</section>
<section class="data-section">
<h2>环境监测</h2>
<div class="environment-values">
<div><svg><use href="#i-sun" /></svg><span>舱外温度</span><strong>{{ withUnit(record.environment?.outsideTemperature, '℃') }}</strong></div>
<div><svg><use href="#i-sun" /></svg><span>舱内温度</span><strong>{{ withUnit(record.environment?.insideTemperature, '℃') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>舱外湿度</span><strong>{{ withUnit(record.environment?.outsideHumidity, '%RH') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>舱内湿度</span><strong>{{ withUnit(record.environment?.insideHumidity, '%RH') }}</strong></div>
<div><svg><use href="#i-wind" /></svg><span>实时风速</span><strong>{{ withUnit(record.environment?.windSpeed, ' m/s') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>降雨</span><strong>{{ booleanText(record.environment?.rain) }}</strong></div>
</div>
</section>
</div>
<div class="detail-side-column">
<section class="data-section">
<div class="data-section-title"><h2>绑定无人机</h2></div>
<div class="bound-asset-large">
<span><svg><use href="#i-plane" /></svg></span>
<div>
<strong>{{ boundDrone?.name || record.binding }}</strong>
<small>{{ boundDrone ? `${boundDrone.status} · 电量 ${boundDrone.battery}` : (record.bindingState === 'bound' ? '绑定关系正常' : '当前未绑定设备') }}</small>
</div>
<i v-if="boundDrone" class="status" :class="boundDrone.statusClass">{{ boundDrone.status }}</i>
</div>
<dl>
<div><dt>飞控 SN</dt><dd>{{ boundDrone?.sn || '--' }}</dd></div>
<div><dt>定位状态</dt><dd>{{ boundDrone?.gps || '--' }}</dd></div>
<div><dt>当前任务</dt><dd>{{ boundDrone?.mission || '--' }}</dd></div>
</dl>
</section>
<section class="data-section" v-if="latestLog">
<h2>最近流程</h2>
<div class="workflow-summary">
<span class="workflow-icon"><svg><use href="#i-home" /></svg></span>
<div><strong>{{ latestLog.commandName }}</strong><small>{{ latestLog.createdAt }}</small></div>
<i>{{ latestLog.statusName }}</i>
</div>
</section>
<section class="data-section">
<div class="data-section-title"><h2>设备信息</h2><button v-if="!isAdmin" @click="editDockVisible = true">编辑</button></div>
<dl class="compact-properties">
<div><dt>机巢 SN</dt><dd>{{ record.sn }}</dd></div>
<div><dt>通信卡 ICCID</dt><dd>{{ record.iccid }}</dd></div>
<div><dt>设备位置</dt><dd>{{ record.location }}</dd></div>
<div><dt>经纬度</dt><dd>{{ coordinateText }}</dd></div>
<div><dt>软件版本</dt><dd>{{ record.version }}</dd></div>
</dl>
</section>
</div>
</div>
</template>
<template v-else>
<div class="detail-metric-band">
<div><span>飞行状态</span><strong :class="{ 'blue-text': record.statusClass === 'mission' }">{{ record.status }}</strong></div>
<div><span>剩余电量</span><strong>{{ percentValue(record.battery) }}</strong></div>
<div><span>相对高度</span><strong>{{ withUnit(asset?.altitude, ' m') }}</strong></div>
<div><span>地速</span><strong>{{ withUnit(asset?.speed, ' m/s') }}</strong></div>
<div><span>卫星数量</span><strong>{{ displayValue(asset?.satellites) }}</strong></div>
<div><span>链路质量</span><strong>{{ withUnit(rt('linkQuality'), '%') }}</strong></div>
</div>
<div class="detail-columns">
<div class="detail-main-column">
<section class="data-section">
<h2>飞行数据</h2>
<div class="value-table">
<div><span>飞行模式</span><strong>{{ record.mode }}</strong></div>
<div><span>解锁状态</span><strong>{{ armedText }}</strong></div>
<div><span>GPS 状态</span><strong class="green-text">{{ record.gps }}</strong></div>
<div><span>电池电压</span><strong>{{ withUnit(rt('batteryVoltage'), ' V') }}</strong></div>
<div><span>航向角</span><strong>{{ withUnit(rt('yaw'), '°') }}</strong></div>
<div><span>返航点</span><strong>{{ homeSetText }}</strong></div>
</div>
</section>
<section class="data-section">
<h2>当前任务</h2>
<div class="mission-detail-row">
<div><strong>{{ record.mission && record.mission !== '--' ? record.mission : '暂无任务' }}</strong><small>{{ record.binding }}</small></div>
</div>
</section>
</div>
<div class="detail-side-column">
<section class="data-section">
<h2>所属机巢</h2>
<div class="bound-asset-large dock">
<span><svg><use href="#i-home" /></svg></span>
<div><strong>{{ boundDock?.name || record.binding }}</strong><small>{{ record.bindingState === 'bound' ? '绑定关系正常' : '当前未绑定设备' }}</small></div>
<i v-if="boundDock" class="status" :class="boundDock.statusClass">{{ boundDock.status }}</i>
</div>
</section>
<section class="data-section">
<h2>实时位置</h2>
<dl class="compact-properties">
<div><dt>经度</dt><dd>{{ displayValue(rt('longitude')) }}</dd></div>
<div><dt>纬度</dt><dd>{{ displayValue(rt('latitude')) }}</dd></div>
<div><dt>Roll</dt><dd>{{ withUnit(rt('roll'), '°') }}</dd></div>
<div><dt>Pitch</dt><dd>{{ withUnit(rt('pitch'), '°') }}</dd></div>
<div><dt>Yaw</dt><dd>{{ withUnit(rt('yaw'), '°') }}</dd></div>
</dl>
</section>
</div>
</div>
</template>
</div>
<div v-else-if="activeTab === 'state'" class="full-detail-content">
<template v-if="!isDrone">
<div class="state-groups">
<section class="data-section">
<h2>舱门与归中机构</h2>
<div class="state-detail-grid">
<div><span>左舱门</span><strong>{{ doorText('left') }}</strong><small>关门反馈{{ completeText(rt('doorCloseComplete')) }}</small></div>
<div><span>右舱门</span><strong>{{ doorText('right') }}</strong><small>开门反馈{{ completeText(rt('doorOpenComplete')) }}</small></div>
<div><span>左右归中</span><strong>{{ centeringText('leftRight') }}</strong><small>夹紧反馈{{ completeText(rt('centeringTightComplete')) }}</small></div>
<div><span>前后归中</span><strong>{{ centeringText('frontBack') }}</strong><small>松开反馈{{ completeText(rt('centeringLooseComplete')) }}</small></div>
</div>
</section>
<section class="data-section">
<h2>流程反馈</h2>
<div class="state-detail-grid">
<div><span>起飞准备</span><strong>{{ completeText(rt('takeoffPreparationComplete')) }}</strong><small>takeoffPreparationComplete</small></div>
<div><span>起飞收尾</span><strong>{{ completeText(rt('hangarActionComplete')) }}</strong><small>hangarActionComplete</small></div>
<div><span>降落准备</span><strong>{{ completeText(rt('landingPreparationComplete')) }}</strong><small>landingPreparationComplete</small></div>
<div><span>降落收尾</span><strong>{{ completeText(rt('landingExecutionComplete')) }}</strong><small>landingExecutionComplete</small></div>
<div><span>整机复位</span><strong>{{ completeText(rt('resetComplete')) }}</strong><small>resetComplete</small></div>
<div><span>急停状态</span><strong>{{ isTrue(rt('emergencyStop')) ? '已触发' : isFalse(rt('emergencyStop')) ? '未触发' : '--' }}</strong><small>emergencyStop</small></div>
</div>
</section>
</div>
</template>
<template v-else>
<div class="state-groups">
<section class="data-section">
<h2>飞控状态</h2>
<div class="state-detail-grid">
<div><span>在线状态</span><strong>{{ record.online ? '在线' : '离线' }}</strong><small>{{ record.updated }}</small></div>
<div><span>解锁状态</span><strong>{{ armedText }}</strong><small>armed</small></div>
<div><span>飞行模式</span><strong>{{ record.mode }}</strong><small>mode code{{ displayValue(rt('flightModeCode')) }}</small></div>
<div><span>返航点</span><strong>{{ homeSetText }}</strong><small>homeSet</small></div>
</div>
</section>
<section class="data-section">
<h2>任务状态</h2>
<div class="state-detail-grid">
<div><span>任务状态</span><strong>{{ record.statusClass === 'mission' ? '执行中' : displayValue(record.mission) }}</strong><small>{{ record.status }}</small></div>
<div><span>飞控 SN</span><strong>{{ record.sn }}</strong><small>已同步</small></div>
<div><span>卫星数量</span><strong>{{ displayValue(asset?.satellites) }}</strong><small>GPS {{ record.gps }}</small></div>
<div><span>链路质量</span><strong>{{ withUnit(rt('linkQuality'), '%') }}</strong><small>MAVLink</small></div>
</div>
</section>
</div>
</template>
</div>
<div v-else-if="activeTab === 'alarms'" class="full-detail-content">
<div class="record-header">
<div><h2>告警记录</h2><span>当前设备</span></div>
<select v-model="alarmStatusFilter">
<option value="">全部告警状态</option>
<option value="active">未恢复</option>
<option value="resolved">已恢复</option>
</select>
</div>
<table class="record-table">
<thead><tr><th>告警时间</th><th>告警编码</th><th>告警内容</th><th>级别</th><th>状态</th><th>恢复时间</th></tr></thead>
<tbody>
<tr v-for="item in filteredAlarms" :key="item.id">
<td>{{ item.triggeredAt }}</td>
<td>{{ item.code }}</td>
<td>{{ item.message }}</td>
<td><i class="severity" :class="item.levelClass">{{ item.levelName }}</i></td>
<td><span class="table-status" :class="item.statusClass"><i></i>{{ item.statusName }}</span></td>
<td>{{ item.resolvedAt }}</td>
</tr>
</tbody>
</table>
<div v-if="!filteredAlarms.length" class="table-empty">暂无告警记录</div>
</div>
<div v-else-if="activeTab === 'logs'" class="full-detail-content">
<div class="record-header">
<div><h2>操作记录</h2><span>远程控制与配置变更</span></div>
<button @click="loadLogs">刷新</button>
</div>
<table class="record-table">
<thead><tr><th>操作时间</th><th>操作类型</th><th>操作内容</th><th>执行结果</th><th>目标设备</th></tr></thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td>{{ log.createdAt }}</td>
<td>{{ log.category }}</td>
<td>{{ log.commandName }}</td>
<td><span :class="log.resultClass">{{ log.result }}</span></td>
<td>{{ log.droneSn }}</td>
</tr>
</tbody>
</table>
<div v-if="!logs.length" class="table-empty">暂无设备操作记录</div>
</div>
<div v-else class="full-detail-content">
<template v-if="!isDrone">
<div class="diagnostics-layout">
<section class="data-section">
<h2>边缘服务</h2>
<div class="diagnostic-list">
<div><span><i :class="record.online ? 'ok' : 'standby'"></i>MQTT 连接</span><strong>{{ record.online ? '正常' : '离线' }}</strong><small>{{ record.updated }}</small></div>
<div><span><i :class="isTrue(rt('plcConnected')) ? 'ok' : 'standby'"></i>PLC / Modbus</span><strong>{{ booleanText(rt('plcConnected')) === '是' ? '正常' : booleanText(rt('plcConnected')) }}</strong><small>plcConnected</small></div>
<div><span><i class="standby"></i>MAVLink</span><strong>--</strong><small>未单独上报</small></div>
<div><span><i class="standby"></i>视频服务</span><strong>--</strong><small>见直播状态</small></div>
</div>
</section>
<section class="data-section">
<h2>软件与身份</h2>
<div class="value-table">
<div><span>边缘版本</span><strong>{{ record.version }}</strong></div>
<div><span>协议版本</span><strong>{{ record.protocol }}</strong></div>
<div><span>设备连接</span><strong>{{ record.status }}</strong></div>
<div><span>最近更新</span><strong>{{ record.updated }}</strong></div>
<div><span>告警数量</span><strong>{{ record.alarms?.length ?? 0 }}</strong></div>
<div><span>PLC 连接</span><strong>{{ booleanText(rt('plcConnected')) }}</strong></div>
</div>
</section>
</div>
</template>
<template v-else>
<div class="diagnostics-layout">
<section class="data-section">
<h2>飞控通信</h2>
<div class="diagnostic-list">
<div><span><i :class="record.online ? 'ok' : 'standby'"></i>MAVLink 心跳</span><strong>{{ record.online ? '正常' : '离线' }}</strong><small>{{ record.updated }}</small></div>
<div><span><i :class="record.online ? 'ok' : 'standby'"></i>遥测数据</span><strong>{{ record.online ? '正常' : '--' }}</strong><small>最近 {{ record.updated }}</small></div>
<div><span><i :class="record.sn && record.sn !== '--' ? 'ok' : 'standby'"></i>飞控序列号</span><strong>{{ record.sn && record.sn !== '--' ? '已识别' : '--' }}</strong><small>{{ record.sn }}</small></div>
</div>
</section>
<section class="data-section">
<h2>飞控信息</h2>
<div class="value-table">
<div><span>当前 SysID</span><strong>{{ record.sysid }}</strong></div>
<div><span>飞控版本</span><strong>{{ record.firmwareVer }}</strong></div>
<div><span>机型</span><strong>{{ record.model }}</strong></div>
<div><span>链路质量</span><strong>{{ withUnit(rt('linkQuality'), '%') }}</strong></div>
</div>
</section>
</div>
</template>
</div>
</template>
<DockEditModal :visible="editDockVisible" :dock="record" @close="editDockVisible = false" @saved="handleDockSaved" />
<div v-if="!record" class="table-empty" style="flex:1;display:grid;place-items:center;color:var(--muted);font-size:12px">
未找到该设备
<button class="primary-command" style="margin-top:10px" @click="router.push({ name: 'devices' })">返回设备列表</button>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDeviceStore } from '../store/devices'
import { useUiStore } from '../store/ui'
import { useUserStore } from '../store/user'
import request from '../api/request'
import DockEditModal from '../components/DockEditModal.vue'
const route = useRoute()
const router = useRouter()
const devices = useDeviceStore()
const ui = useUiStore()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const activeTab = ref('overview')
const logs = ref([])
const alarmRecords = ref([])
const alarmStatusFilter = ref('')
const editDockVisible = ref(false)
const record = computed(() => devices.record(route.params.id))
const asset = computed(() => devices.asset(route.params.id))
const isDrone = computed(() => record.value?.kind === 'drone')
const boundDrone = computed(() => (record.value?.kind === 'dock' ? devices.drones.find((d) => d.dockId === record.value.dockId) : null))
const boundDock = computed(() => (record.value?.kind === 'drone' ? devices.docks.find((d) => d.dockId === record.value.dockId) : null))
const latestLog = computed(() => logs.value[0] || null)
const coordinateText = computed(() => {
const rec = record.value
if (!rec || rec.longitude == null || rec.latitude == null) return '--'
return `${rec.longitude}, ${rec.latitude}`
})
const chargeTone = computed(() => String(rt('chargingState') || '').toLowerCase() === 'charging')
const doorSummary = computed(() => {
const left = doorValue('left')
const right = doorValue('right')
if (isClosed(left) && isClosed(right)) return '已关闭'
if (left === 'open' || right === 'open') return '已打开'
return displayValue(left || right)
})
const armedText = computed(() => {
if (isTrue(rt('armed'))) return '已解锁'
if (isFalse(rt('armed'))) return '未解锁'
return '--'
})
const homeSetText = computed(() => {
if (isTrue(rt('homeSet'))) return '已设置'
if (isFalse(rt('homeSet'))) return '未设置'
return '--'
})
const filteredAlarms = computed(() => {
if (!alarmStatusFilter.value) return alarmRecords.value
if (alarmStatusFilter.value === 'active') return alarmRecords.value.filter((item) => item.status !== 'resolved')
return alarmRecords.value.filter((item) => item.status === alarmStatusFilter.value)
})
function percentValue(value) {
return value == null || value === '' || value === '--' ? '--' : `${value}`
}
function displayValue(value) {
return value == null || value === '' ? '--' : value
}
function withUnit(value, unit) {
return value == null || value === '' ? '--' : `${value}${unit}`
}
function isTrue(value) {
return value === true || value === 'true' || value === '1' || value === 1
}
function isFalse(value) {
return value === false || value === 'false' || value === '0' || value === 0
}
function booleanText(value) {
if (isTrue(value)) return '是'
if (isFalse(value)) return '否'
return '--'
}
function completeText(value) {
if (isTrue(value)) return '已完成'
if (isFalse(value)) return '未执行'
return '--'
}
function rt(key, ...aliases) {
const realtime = record.value?.realtime || {}
for (const name of [key, ...aliases]) {
if (realtime[name] != null && realtime[name] !== '') return realtime[name]
}
return null
}
function rtObj(key) {
const raw = record.value?.realtime?.[key]
if (!raw) return {}
if (typeof raw === 'object') return raw
try { return JSON.parse(raw) || {} } catch { return {} }
}
function doorValue(side) {
return rtObj('door')[side]
}
function centeringValue(key) {
return rtObj('centering')[key]
}
function isClosed(value) {
return value === 'closed' || value === 'close'
}
function isTight(value) {
return value === 'tight'
}
function doorText(side) {
const value = doorValue(side)
if (isClosed(value)) return '已关闭'
if (value === 'open') return '已打开'
return displayValue(value)
}
function centeringText(key) {
const value = centeringValue(key)
if (value === 'tight') return '已夹紧'
if (value === 'loose') return '已松开'
return displayValue(value)
}
function chargeText(value) {
const key = String(value || '').toLowerCase()
if (key === 'charging') return '充电中'
if (key === 'idle') return '空闲'
if (key === 'full' || key === 'charged') return '已充满'
return displayValue(value)
}
onMounted(async () => {
try {
await devices.load()
await Promise.all([loadLogs(), loadAlarms()])
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
})
const COMMAND_NAMES = {
'dock.open': '打开舱门',
'dock.close': '关闭舱门',
'dock.reset': '整机复位',
'dock.prepare_takeoff': '起飞准备',
'dock.complete_takeoff': '起飞准备完成',
'dock.prepare_landing': '降落准备',
'dock.complete_landing': '降落准备完成',
'dock.charge_start': '开始充电',
'dock.drone_power_on': '无人机开机',
'dock.drone_power_off': '无人机关机',
'dock.reboot': '远程重启',
'dock.clear_alarm': '清除设备告警',
'drone.takeoff': '无人机起飞',
'drone.land': '无人机降落',
'drone.return': '一键返航',
'drone.mission_upload': '上传任务',
'drone.mission_start': '开始任务',
'drone.mission_pause': '暂停任务',
'drone.mission_resume': '继续任务',
'drone.mission_cancel': '取消任务',
'workflow.start_task': '执行任务',
'workflow.one_key_takeoff': '一键起飞',
'workflow.one_key_land': '一键降落',
'video.start_stream': '开始直播',
'video.stop_stream': '停止直播'
}
const STATUS_NAMES = {
sent: { name: '已下发', cls: 'processing', result: '等待应答' },
acked: { name: '已确认', cls: 'result-success', result: '执行成功' },
timeout: { name: '已超时', cls: 'offline', result: '已超时' },
terminal: { name: '执行失败', cls: 'offline', result: '执行失败' }
}
const ALARM_LEVELS = {
critical: { name: '紧急', cls: 'high' },
high: { name: '紧急', cls: 'high' },
warning: { name: '一般', cls: 'medium' },
medium: { name: '一般', cls: 'medium' },
info: { name: '提示', cls: 'low' },
low: { name: '提示', cls: 'low' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
return Number.isNaN(d.getTime()) ? '--' : d.toLocaleString('zh-CN', { hour12: false })
}
async function loadLogs() {
const current = record.value
if (!current) return
try {
const page = await request.get('/v1/commands', { params: { pageNum: 1, pageSize: 100, dockId: current.dockId } })
logs.value = (page?.records || []).filter((log) => current.kind !== 'drone' || log.droneSn === current.droneSn).map((log) => {
const status = STATUS_NAMES[log.status] || { name: log.status || '--', cls: 'offline', result: log.status || '--' }
const type = log.commandType || ''
return {
id: String(log.id),
category: type.startsWith('workflow.') ? '工作流' : '控制指令',
commandName: COMMAND_NAMES[type] || type || '--',
droneSn: log.droneSn || '机巢本体',
statusName: status.name,
result: log.ackResultCode || status.result,
resultClass: log.status === 'acked' ? 'result-success' : '',
createdAt: fmtTime(log.createdAt)
}
})
} catch (e) {
ui.toast(e.message || '加载设备操作记录失败')
}
}
async function loadAlarms() {
const current = record.value
if (!current) return
try {
const page = await request.get('/v1/alarms', {
params: {
pageNum: 1,
pageSize: 100,
dockId: current.dockId,
deviceType: current.kind === 'drone' ? 'drone' : 'dock'
}
})
alarmRecords.value = (page?.records || []).map((item) => {
const level = ALARM_LEVELS[item.level] || { name: item.level || '--', cls: 'medium' }
const resolved = item.status === 'resolved'
return {
id: String(item.id),
code: item.code || '--',
message: item.messageCn || item.code || '--',
levelName: level.name,
levelClass: level.cls,
status: item.status,
statusName: resolved ? '已恢复' : item.status === 'acknowledged' ? '已确认' : '未恢复',
statusClass: resolved ? 'online' : 'offline',
triggeredAt: fmtTime(item.triggeredAt || item.createdAt),
resolvedAt: fmtTime(item.resolvedAt)
}
})
} catch (e) {
ui.toast(e.message || '加载告警记录失败')
}
}
async function handleDockSaved() {
editDockVisible.value = false
try {
await devices.load()
ui.toast('机巢信息已更新')
} catch (e) {
ui.toast(e.message || '机巢已保存,但刷新设备数据失败')
}
}
function goControl() {
router.push({ name: 'monitor', query: { deviceId: route.params.id, control: '1' } })
}
function goMonitor() {
router.push({ name: 'monitor', query: { deviceId: route.params.id } })
}
</script>

512
src/views/Devices.vue

@ -0,0 +1,512 @@
<template>
<section class="device-page">
<header class="device-page-header">
<div>
<h1>设备管理</h1>
<span>{{ updatedLabel }}</span>
</div>
<div class="page-actions">
<button class="icon-command" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button>
<button v-if="!isAdmin" class="primary-command" @click="openAddDock"><svg><use href="#i-plus" /></svg>添加机巢</button>
</div>
</header>
<div class="asset-overview">
<div><span>机巢总数</span><strong>{{ overview.docks }}</strong><small></small></div>
<div><span>在线设备</span><strong class="green-text">{{ overview.online }}</strong><small></small></div>
<div><span>执行任务</span><strong class="blue-text">{{ overview.mission }}</strong><small></small></div>
<div><span>当前告警</span><strong class="red-text">{{ overview.alarm }}</strong><small></small></div>
<div><span>绑定无人机</span><strong>{{ overview.boundDrones }}</strong><small></small></div>
</div>
<div class="asset-workspace">
<div class="asset-tabs">
<button class="asset-tab" :class="{ active: tab === 'docks' }" @click="setTab('docks')">机巢设备<b>{{ devices.docks.length }}</b></button>
<button class="asset-tab" :class="{ active: tab === 'drones' }" @click="setTab('drones')">无人机<b>{{ devices.drones.length }}</b></button>
</div>
<div class="asset-toolbar">
<div class="asset-search"><svg><use href="#i-search" /></svg><input v-model="search" type="text" placeholder="搜索设备名称、编号或 SN" /></div>
<div class="toolbar-group">
<select v-model="statusFilter" aria-label="设备状态">
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="mission">任务中</option>
<option value="alarm">告警</option>
<option value="offline">离线</option>
<option value="pending">待连接</option>
</select>
<select v-model="bindingFilter" aria-label="绑定状态">
<option value="">全部绑定状态</option>
<option value="bound">已绑定</option>
<option value="unbound">未绑定</option>
</select>
<button class="plain-command" type="button" @click="resetFilters">重置</button>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr v-if="tab === 'docks'">
<th style="width:18%">机巢设备</th>
<th>状态</th>
<th>绑定无人机</th>
<th>控制模式</th>
<th>设备位置</th>
<th>软件版本</th>
<th>最近上报</th>
<th style="width:20%">操作</th>
</tr>
<tr v-else>
<th style="width:18%">无人机</th>
<th>状态</th>
<th>所属机巢</th>
<th>电量</th>
<th>飞行模式</th>
<th>当前任务</th>
<th>最近上报</th>
<th style="width:22%">操作</th>
</tr>
</thead>
<tbody v-if="tab === 'docks'">
<tr v-for="d in filteredDocks" :key="d.id" @click="openDrawer(d.id)">
<td>
<div class="asset-name-cell">
<span class="table-asset-icon"><svg><use href="#i-home" /></svg></span>
<span><strong>{{ d.name }}</strong><small>{{ d.code }}</small></span>
</div>
</td>
<td><span class="table-status" :class="d.statusClass"><i></i>{{ d.status }}</span></td>
<td><span class="binding-cell" :class="{ unbound: d.bindingState === 'unbound' }"><svg><use href="#i-link" /></svg>{{ d.binding }}</span></td>
<td>{{ d.mode }}</td>
<td>{{ d.location }}</td>
<td>{{ d.version }}</td>
<td>{{ d.updated }}</td>
<td>
<div class="row-actions">
<button v-if="!isAdmin" @click.stop="openFirmwareUpgrade(d.id)">固件升级</button>
<button v-if="!isAdmin" @click.stop="goControl(d.id)">远程控制</button>
<button @click.stop="goDetail(d.id)">详情</button>
</div>
</td>
</tr>
<tr v-if="!filteredDocks.length"><td class="table-empty" colspan="8">暂无符合条件的设备</td></tr>
</tbody>
<tbody v-else>
<tr v-for="d in filteredDrones" :key="d.id" @click="openDrawer(d.id)">
<td>
<div class="asset-name-cell">
<span class="table-asset-icon drone"><svg><use href="#i-plane" /></svg></span>
<span><strong>{{ d.name }}</strong><small>{{ d.code }}</small></span>
</div>
</td>
<td><span class="table-status" :class="d.statusClass"><i></i>{{ d.status }}</span></td>
<td><span class="binding-cell"><svg><use href="#i-link" /></svg>{{ d.binding }}</span></td>
<td>{{ d.battery }}</td>
<td>{{ d.mode }}</td>
<td>{{ d.mission }}</td>
<td>{{ d.updated }}</td>
<td>
<div class="row-actions">
<button v-if="!isAdmin" @click.stop="ui.toast('请选择航线后上传')">航线上传</button>
<button v-if="!isAdmin" @click.stop="goControl(d.id)">远程控制</button>
<button @click.stop="goDetail(d.id)">详情</button>
</div>
</td>
</tr>
<tr v-if="!filteredDrones.length"><td class="table-empty" colspan="8">暂无符合条件的设备</td></tr>
</tbody>
</table>
</div>
<div class="table-footer">
<span> {{ tab === 'docks' ? filteredDocks.length : filteredDrones.length }} </span>
<div>
<button disabled><svg><use href="#i-chevron" /></svg></button>
<b>1</b>
<button disabled><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
<!-- Drawer -->
<div class="drawer-scrim" :class="{ open: drawerOpen }" @click="drawerOpen = false"></div>
<aside class="device-drawer" :class="{ open: drawerOpen }">
<template v-if="drawerRecord">
<header>
<div class="drawer-identity">
<span :class="{ drone: drawerRecord.kind === 'drone' }"><svg><use :href="drawerRecord.kind === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
<div>
<small>{{ drawerRecord.kind === 'drone' ? '无人机设备' : '机巢设备' }}</small>
<h2>{{ drawerRecord.name }}</h2>
<p>{{ drawerRecord.code }}</p>
</div>
</div>
<button class="icon-button" @click="drawerOpen = false"><svg><use href="#i-x" /></svg></button>
</header>
<div class="drawer-status">
<em class="status" :class="drawerRecord.statusClass">{{ drawerRecord.status }}</em>
<span>{{ drawerStatusText }}</span>
</div>
<section class="drawer-section">
<h3>基本信息</h3>
<dl class="property-list">
<template v-if="drawerRecord.kind === 'dock'">
<div><dt>机巢 SN</dt><dd>{{ drawerRecord.sn }}</dd></div>
<div><dt>通信卡 ICCID</dt><dd>{{ drawerRecord.iccid }}</dd></div>
<div><dt>设备位置</dt><dd>{{ drawerRecord.location }}</dd></div>
<div><dt>经度</dt><dd>{{ displayCoord(drawerRecord.longitude) }}</dd></div>
<div><dt>纬度</dt><dd>{{ displayCoord(drawerRecord.latitude) }}</dd></div>
<div><dt>控制模式</dt><dd>{{ drawerRecord.mode }}</dd></div>
<div><dt>软件版本</dt><dd>{{ drawerRecord.version }}</dd></div>
</template>
<template v-else>
<div><dt>飞控 SN</dt><dd>{{ drawerRecord.sn }}</dd></div>
<div><dt>当前 SysID</dt><dd>{{ drawerRecord.sysid }}</dd></div>
<div><dt>飞行模式</dt><dd>{{ drawerRecord.mode }}</dd></div>
<div><dt>剩余电量</dt><dd>{{ drawerRecord.battery }}</dd></div>
<div><dt>定位状态</dt><dd>{{ drawerRecord.gps }}</dd></div>
<div><dt>最近上报</dt><dd>{{ drawerRecord.updated }}</dd></div>
</template>
</dl>
</section>
<section class="drawer-section">
<div class="drawer-section-title"><h3>绑定关系</h3><button v-if="!isAdmin" @click="ui.toast('管理绑定')">管理绑定</button></div>
<button class="binding-device" @click="goBinding">
<span><svg><use :href="drawerRecord.kind === 'drone' ? '#i-home' : '#i-plane'" /></svg></span>
<div><strong>{{ drawerRecord.binding }}</strong><small>{{ drawerBindingText }}</small></div>
<svg><use href="#i-chevron" /></svg>
</button>
</section>
<footer>
<button v-if="!isAdmin && drawerRecord.kind === 'dock'" class="plain-command" @click="openEditDock(drawerRecord)">编辑信息</button>
<button class="primary-command" @click="goDetail(drawerRecord.id)">查看完整详情</button>
</footer>
</template>
</aside>
<div class="modal-backdrop" :class="{ open: addDockVisible }" @click.self="addDockVisible = false">
<div class="modal add-dock-dialog" role="dialog" aria-modal="true" aria-label="添加机巢">
<header>
<div><span>设备管理</span><h3>添加机巢</h3></div>
<button class="icon-button" type="button" @click="addDockVisible = false"><svg><use href="#i-x" /></svg></button>
</header>
<form novalidate @submit.prevent="submitAddDock">
<label><span>机巢名称<b>*</b></span><input v-model="dockForm.name" type="text" maxlength="30" placeholder="例如:青山湖 03 号机巢" /></label>
<label><span>机巢 SN<b>*</b></span><input v-model="dockForm.sn" type="text" maxlength="40" placeholder="请输入机身铭牌 SN" /></label>
<label><span>通信卡 ICCID<b>*</b></span><input v-model="dockForm.iccid" type="text" maxlength="22" inputmode="numeric" placeholder="请输入通信卡 ICCID" /></label>
<div class="form-row">
<label><span>经度<b>*</b></span><input v-model.number="dockForm.longitude" type="number" min="-180" max="180" step="any" placeholder="例如:120.219375" /></label>
<label><span>纬度<b>*</b></span><input v-model.number="dockForm.latitude" type="number" min="-90" max="90" step="any" placeholder="例如:30.259244" /></label>
</div>
<label><span>设备位置<em>选填</em></span><input v-model="dockForm.location" type="text" maxlength="50" placeholder="例如:青山湖科技城 C 区" /></label>
<div class="form-error">{{ addDockError }}</div>
<footer>
<button class="plain-command" type="button" @click="addDockVisible = false">取消</button>
<button class="primary-command" type="submit">确认添加</button>
</footer>
</form>
</div>
</div>
<DockEditModal :visible="editDockVisible" :dock="editDockRecord" @close="editDockVisible = false" @saved="handleDockSaved" />
<div class="modal-backdrop" :class="{ open: firmwareVisible }" @click.self="firmwareVisible = false">
<div class="modal add-dock-dialog firmware-dialog" role="dialog" aria-modal="true" aria-label="固件升级">
<header>
<div><span>设备管理 · 固件维护</span><h3>固件升级</h3></div>
<button class="icon-button" type="button" @click="firmwareVisible = false"><svg><use href="#i-x" /></svg></button>
</header>
<div class="firmware-content" v-if="firmwareRecord">
<div class="firmware-device">
<span class="table-asset-icon"><svg><use href="#i-home" /></svg></span>
<div>
<strong>{{ firmwareRecord.name }}</strong>
<small>当前版本 {{ firmwareRecord.version }}</small>
</div>
<i class="status" :class="firmwareRecord.statusClass">{{ firmwareRecord.status }}</i>
</div>
<label>
<span>目标固件版本</span>
<select v-model="upgradeFirmwareId">
<option :value="null" disabled>{{ firmwareList.length ? '选择目标版本' : '暂无已发布固件' }}</option>
<option v-for="f in firmwareList" :key="f.id" :value="f.id">{{ firmwareOptionLabel(f) }}</option>
</select>
</label>
<div class="firmware-checks">
<span>升级前请确认设备状态</span>
</div>
<p class="firmware-note">升级期间机巢将暂时停止远程控制和视频服务请确认当前没有飞行任务升级完成后设备会自动重启并恢复连接</p>
<div class="form-error">{{ firmwareError }}</div>
</div>
<footer class="firmware-footer">
<button class="plain-command" type="button" @click="firmwareVisible = false">取消</button>
<button class="primary-command" type="button" :disabled="!upgradeFirmwareId || upgrading" @click="doUpgrade">{{ upgrading ? '下发中' : '开始升级' }}</button>
</footer>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useDeviceStore } from '../store/devices'
import { useUiStore } from '../store/ui'
import { useRouter } from 'vue-router'
import { useUserStore } from '../store/user'
import request from '../api/request'
import DockEditModal from '../components/DockEditModal.vue'
const devices = useDeviceStore()
const ui = useUiStore()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const tab = ref('docks')
onMounted(async () => {
try {
await devices.load()
loadedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
if (!isAdmin.value) loadFirmwares()
})
const search = ref('')
const statusFilter = ref('')
const bindingFilter = ref('')
const loadedAt = ref('')
const drawerOpen = ref(false)
const drawerId = ref(null)
const addDockVisible = ref(false)
const editDockVisible = ref(false)
const editDockRecord = ref(null)
const addDockError = ref('')
const dockForm = reactive({ name: '', sn: '', iccid: '', longitude: '', latitude: '', location: '' })
const firmwareList = ref([])
const upgradeFirmwareId = ref(null)
const upgrading = ref(false)
const firmwareVisible = ref(false)
const firmwareError = ref('')
const firmwareRecord = computed(() => (drawerId.value ? devices.record(drawerId.value) : null))
const drawerRecord = computed(() => (drawerId.value ? devices.record(drawerId.value) : null))
const overview = computed(() => {
const all = [...devices.docks, ...devices.drones]
return {
docks: devices.docks.length,
online: all.filter((d) => d.statusClass === 'online' || d.statusClass === 'mission').length,
mission: all.filter((d) => d.statusClass === 'mission').length,
alarm: all.filter((d) => d.statusClass === 'alarm').length,
boundDrones: devices.drones.filter((d) => d.bindingState === 'bound').length
}
})
const updatedLabel = computed(() => (loadedAt.value ? `数据更新于 ${loadedAt.value}` : '管理机巢与无人机设备的接入、绑定与运行状态'))
const drawerStatusText = computed(() => {
const rec = drawerRecord.value
if (!rec) return ''
if (rec.statusClass === 'pending') return '机巢已添加,等待设备连接'
if (rec.statusClass === 'offline') return `设备最后上报于 ${rec.updated}`
return `状态数据更新于 ${rec.updated}`
})
const drawerBindingText = computed(() => {
const rec = drawerRecord.value
if (!rec) return ''
if (rec.kind === 'dock' && rec.bindingState === 'bound') {
const drone = devices.drones.find((d) => d.dockId === rec.dockId)
return drone?.battery && drone.battery !== '--' ? `已入巢 · 电量 ${drone.battery}` : '绑定关系正常'
}
return rec.bindingState === 'bound' ? '绑定关系正常' : '当前未绑定设备'
})
function displayCoord(value) {
return value == null || value === '' ? '--' : value
}
function matchesFilters(d) {
const q = search.value.trim().toLowerCase()
if (statusFilter.value && d.statusClass !== statusFilter.value) return false
if (bindingFilter.value && d.bindingState !== bindingFilter.value) return false
if (!q) return true
const haystack = [d.name, d.code, d.sn, d.location, d.binding].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
}
const filteredDocks = computed(() => devices.docks.filter(matchesFilters))
const filteredDrones = computed(() => devices.drones.filter(matchesFilters))
function setTab(next) {
tab.value = next
resetFilters()
drawerOpen.value = false
}
function resetFilters() {
search.value = ''
statusFilter.value = ''
bindingFilter.value = ''
}
function openDrawer(id) {
drawerId.value = id
drawerOpen.value = true
}
function goControl(id) {
router.push({ name: 'monitor', query: { deviceId: id, control: '1' } })
}
async function reload() {
try {
await devices.load()
loadedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
ui.toast('设备列表已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备列表失败')
}
}
function goDetail(id) {
drawerOpen.value = false
router.push({ name: 'device-detail', params: { id } })
}
async function openFirmwareUpgrade(id) {
drawerId.value = id
firmwareError.value = ''
upgradeFirmwareId.value = null
firmwareVisible.value = true
await loadFirmwares()
if (!firmwareList.value.length && !firmwareError.value) {
firmwareError.value = '暂无已发布固件,请先在固件管理中发布版本。'
}
}
function goBinding() {
ui.toast('请选择要绑定的设备')
}
function openEditDock(dock) {
editDockRecord.value = dock
editDockVisible.value = true
}
async function handleDockSaved() {
editDockVisible.value = false
try {
await devices.load()
ui.toast('机巢信息已更新')
} catch (e) {
ui.toast(e.message || '机巢已保存,但刷新设备数据失败')
}
}
function openAddDock() {
Object.assign(dockForm, { name: '', sn: '', iccid: '', longitude: '', latitude: '', location: '' })
addDockError.value = ''
addDockVisible.value = true
}
async function submitAddDock() {
addDockError.value = ''
const name = dockForm.name.trim()
const sn = dockForm.sn.trim().toUpperCase()
const iccid = dockForm.iccid.trim().replace(/\s+/g, '')
const longitude = Number(dockForm.longitude)
const latitude = Number(dockForm.latitude)
if (!name || !sn || !iccid || dockForm.longitude === '' || dockForm.latitude === '') {
addDockError.value = '请填写机巢名称、机巢 SN、通信卡 ICCID、经度和纬度。'
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
addDockError.value = '经度应在 -180~180 之间。'
return
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
addDockError.value = '纬度应在 -90~90 之间。'
return
}
if (devices.docks.some((dock) => dock.sn.toUpperCase() === sn || dock.iccid.replace(/--/g, '') === iccid)) {
addDockError.value = '机巢 SN 或通信卡 ICCID 已存在。'
return
}
try {
await request.post('/v1/docks', {
dockId: sn,
name,
code: sn,
sn,
iccid,
location: dockForm.location.trim(),
longitude,
latitude
})
addDockVisible.value = false
ui.toast('机巢已成功接入')
await devices.load()
} catch (e) {
addDockError.value = e.message || '接入失败'
}
}
async function loadFirmwares() {
try {
const list = await request.get('/v1/firmwares/released')
const records = Array.isArray(list) ? list : list?.records || []
firmwareList.value = records.map((f) => ({
id: String(f.id),
version: f.version,
description: f.description || '',
mandatory: Number(f.mandatory) === 1
}))
} catch (e) {
firmwareList.value = []
firmwareError.value = e.message || '加载固件版本失败'
}
}
function firmwareOptionLabel(f) {
const tag = f.mandatory ? '强制升级' : '推荐稳定版'
return f.description ? `${f.version} · ${tag} · ${f.description}` : `${f.version} · ${tag}`
}
async function doUpgrade() {
const rec = firmwareRecord.value
firmwareError.value = ''
if (!rec || !upgradeFirmwareId.value) {
firmwareError.value = '请选择目标固件版本。'
return
}
if (rec.statusClass === 'offline') {
firmwareError.value = '设备离线,无法下发升级。'
return
}
const ok = await ui.confirm('确认升级固件?', `将把机巢「${rec.name}」升级到所选版本,升级期间设备可能短暂离线。`)
if (!ok) return
upgrading.value = true
try {
await request.post(`/v1/docks/${rec.id}/firmware/upgrade`, { firmwareId: Number(upgradeFirmwareId.value) })
firmwareVisible.value = false
upgradeFirmwareId.value = null
ui.toast('升级指令已下发')
} catch (e) {
firmwareError.value = e.message || '升级下发失败'
} finally {
upgrading.value = false
}
}
</script>

266
src/views/Firmwares.vue

@ -0,0 +1,266 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>固件管理</h1>
<span>维护机巢固件版本发布后供用户对设备升级</span>
</div>
<div class="page-actions">
<button class="icon-command" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></button>
<button class="primary-command" @click="openAdd"><svg><use href="#i-plus" /></svg>新增固件</button>
</div>
</header>
<div class="business-workspace">
<div class="asset-toolbar">
<div class="asset-search"><svg><use href="#i-search" /></svg><input v-model="keyword" type="text" placeholder="搜索版本号 / 描述" @keyup.enter="reload" /></div>
<div class="toolbar-group">
<select v-model="statusFilter" @change="reload">
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="released">已发布</option>
<option value="deprecated">已弃用</option>
</select>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr>
<th style="width:12%">版本</th>
<th style="width:14%">组件</th>
<th style="width:22%">描述</th>
<th style="width:9%">大小</th>
<th style="width:8%">升级方式</th>
<th style="width:9%">状态</th>
<th style="width:16%">创建时间</th>
<th style="width:10%">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in list" :key="f.id">
<td><strong>{{ f.version }}</strong></td>
<td>{{ f.component }}</td>
<td><span :title="f.description">{{ f.description || '—' }}</span></td>
<td>{{ f.sizeText }}</td>
<td>{{ f.mandatory ? '强制' : '普通' }}</td>
<td><span class="table-status" :class="f.statusClass"><i></i>{{ f.statusName }}</span></td>
<td>{{ f.createdAt }}</td>
<td>
<div class="row-actions">
<button @click="openEdit(f)">编辑</button>
<button class="more-action" @click="remove(f)"><svg><use href="#i-trash" /></svg></button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!list.length" class="table-empty">暂无固件版本</div>
</div>
<div class="table-footer">
<span> {{ total }} 个固件版本 · {{ pageNum }} / {{ pages }} </span>
<div>
<button @click="goPage(pageNum - 1)"><svg><use href="#i-chevron" /></svg></button>
<b>{{ pageNum }}</b>
<button @click="goPage(pageNum + 1)"><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
<!-- Add / edit dialog -->
<div class="modal-backdrop" :class="{ open: modalVisible }">
<div class="modal add-dock-dialog" role="dialog" aria-modal="true">
<header>
<div><span>固件版本</span><h3>{{ editingId ? '编辑固件' : '新增固件' }}</h3></div>
<button class="icon-button" @click="modalVisible = false"><svg><use href="#i-x" /></svg></button>
</header>
<form novalidate @submit.prevent="submit">
<div class="form-row">
<label><span>版本号<b>*</b></span><input v-model="form.version" type="text" placeholder="例如 1.3.0" :disabled="!!editingId" /></label>
<label><span>组件</span><input v-model="form.component" type="text" placeholder="dock-edge-agent" /></label>
</div>
<label><span>版本描述</span><input v-model="form.description" type="text" placeholder="本次更新内容摘要" /></label>
<label><span>文件地址<b>*</b></span><input v-model="form.fileUrl" type="text" placeholder="https://.../firmware.bin" /></label>
<div class="form-row">
<label><span>SHA256<b>*</b></span><input v-model="form.sha256" type="text" placeholder="文件校验值" /></label>
<label><span>文件大小(MB)</span><input v-model="form.fileSizeMb" type="number" min="0" placeholder="0" /></label>
</div>
<label><span>签名</span><input v-model="form.signature" type="text" placeholder="固件签名" /></label>
<div class="form-row">
<label><span>升级方式</span>
<select v-model="form.mandatory">
<option :value="0">普通升级</option>
<option :value="1">强制升级</option>
</select>
</label>
<label><span>状态</span>
<select v-model="form.status">
<option value="draft">草稿</option>
<option value="released">已发布</option>
<option value="deprecated">已弃用</option>
</select>
</label>
</div>
<div class="form-error">{{ formError }}</div>
<footer>
<button class="plain-command" type="button" @click="modalVisible = false">取消</button>
<button class="primary-command" type="submit">{{ editingId ? '保存' : '确认新增' }}</button>
</footer>
</form>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const ui = useUiStore()
const list = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const pages = ref(1)
const keyword = ref('')
const statusFilter = ref('')
const modalVisible = ref(false)
const editingId = ref(null)
const formError = ref('')
const form = reactive({ version: '', component: '', description: '', fileUrl: '', sha256: '', signature: '', fileSizeMb: '', mandatory: 0, status: 'draft' })
const STATUS = {
draft: { name: '草稿', cls: 'pending' },
released: { name: '已发布', cls: 'online' },
deprecated: { name: '已弃用', cls: 'offline' }
}
function fmtSize(b) {
if (!b) return '--'
const n = Number(b)
if (n >= 1024 * 1024 * 1024) return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
return n + ' B'
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const page = await request.get('/v1/firmwares', { params })
total.value = page?.total || 0
pages.value = page?.pages || 1
list.value = (page?.records || []).map((f) => ({
id: String(f.id),
version: f.version,
component: f.component,
description: f.description || '',
sizeText: fmtSize(f.fileSize),
mandatory: Number(f.mandatory) === 1,
status: f.status,
statusName: STATUS[f.status]?.name || f.status,
statusClass: STATUS[f.status]?.cls || 'offline',
createdAt: fmtTime(f.createdAt),
raw: f
}))
} catch (e) {
ui.toast(e.message || '加载固件列表失败')
}
}
function reload() {
pageNum.value = 1
load()
}
function goPage(p) {
if (p < 1 || p > pages.value) return
pageNum.value = p
load()
}
function openAdd() {
Object.assign(form, { version: '', component: '', description: '', fileUrl: '', sha256: '', signature: '', fileSizeMb: '', mandatory: 0, status: 'draft' })
editingId.value = null
formError.value = ''
modalVisible.value = true
}
function openEdit(f) {
const r = f.raw
Object.assign(form, {
version: r.version,
component: r.component || '',
description: r.description || '',
fileUrl: r.fileUrl || '',
sha256: r.sha256 || '',
signature: r.signature || '',
fileSizeMb: r.fileSize ? (r.fileSize / 1024 / 1024).toFixed(1) : '',
mandatory: Number(r.mandatory) === 1 ? 1 : 0,
status: r.status || 'draft'
})
editingId.value = f.id
formError.value = ''
modalVisible.value = true
}
async function submit() {
formError.value = ''
if (!form.version || !form.fileUrl || !form.sha256) {
formError.value = '请填写版本号、文件地址和 SHA256。'
return
}
const payload = {
version: form.version,
component: form.component || 'dock-edge-agent',
description: form.description,
fileUrl: form.fileUrl,
sha256: form.sha256,
signature: form.signature || '',
fileSize: Math.round(Number(form.fileSizeMb || 0) * 1024 * 1024),
mandatory: Number(form.mandatory) === 1 ? 1 : 0,
status: form.status || 'draft'
}
try {
if (editingId.value) {
payload.status = form.status
await request.put(`/v1/firmwares/${editingId.value}`, payload)
ui.toast('固件已更新')
} else {
await request.post('/v1/firmwares', payload)
ui.toast(form.status === 'released' ? '固件已新增并发布' : '固件已新增(草稿状态)')
}
modalVisible.value = false
await load()
} catch (e) {
formError.value = e.message || '保存失败'
}
}
async function remove(f) {
const ok = await ui.confirm(`确认删除固件版本 ${f.version}`, '删除后不可恢复,已绑定该版本的设备不受影响。')
if (!ok) return
try {
await request.delete(`/v1/firmwares/${f.id}`)
ui.toast('固件已删除')
await load()
} catch (e) {
ui.toast(e.message || '删除失败')
}
}
onMounted(load)
</script>

197
src/views/Login.vue

@ -0,0 +1,197 @@
<template>
<section class="login-page">
<div class="login-visual">
<div class="login-visual-map"></div>
<div class="login-visual-shade"></div>
<div class="login-brand">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div><strong>嘉谷</strong><small>低空智控平台</small></div>
</div>
<div class="login-visual-copy">
<span>LOW-ALTITUDE INTELLIGENCE</span>
<h1>让每一次飞行<br />都可控可追溯</h1>
<p>统一管理机巢无人机任务与视频资产</p>
</div>
<div class="login-visual-footer"><span>嘉谷低空智控平台</span><span>© 2026 嘉谷科技</span></div>
</div>
<div class="login-form-side">
<div class="login-form-wrap">
<div class="login-panel" :class="{ active: mode === 'login' }">
<div class="login-method-tabs">
<button :class="{ active: method === 'password' }" type="button" @click="method = 'password'">账号密码登录</button>
<button :class="{ active: method === 'sms' }" type="button" @click="method = 'sms'">手机号登录</button>
</div>
<form novalidate @submit.prevent="submitLogin">
<div class="login-method-fields password-fields" v-show="method === 'password'">
<label><span>账号</span><div class="login-input"><svg><use href="#i-user"/></svg><input v-model="account" type="text" placeholder="请输入手机号或企业邮箱" autocomplete="username" /></div></label>
<label><span>密码</span><div class="login-input"><svg><use href="#i-lock"/></svg><input v-model="password" :type="pwdVisible ? 'text' : 'password'" placeholder="请输入登录密码" autocomplete="current-password" /><button type="button" @click="pwdVisible = !pwdVisible" :title="pwdVisible ? '隐藏密码' : '显示密码'"><svg><use href="#i-eye"/></svg></button></div></label>
</div>
<div class="login-method-fields sms-fields" v-show="method === 'sms'">
<label><span>手机号</span><div class="login-input"><svg><use href="#i-phone"/></svg><input v-model="smsPhone" type="tel" placeholder="请输入绑定手机号" /></div></label>
<label><span>短信验证码</span><div class="login-input"><svg><use href="#i-message"/></svg><input v-model="smsCode" inputmode="numeric" maxlength="6" placeholder="请输入短信验证码" /><button class="send-code-button" type="button" :disabled="loginCodeTimer > 0" @click="sendLoginCode">{{ loginCodeTimer > 0 ? loginCodeTimer + 's 后重试' : '获取验证码' }}</button></div></label>
</div>
<label><span>图形验证码</span><div class="login-input captcha-input"><svg><use href="#i-shield"/></svg><input v-model="captcha" maxlength="4" placeholder="请输入验证码" /><button class="captcha-image" type="button" title="刷新验证码" @click="refreshCaptcha">{{ captchaValue }}</button></div></label>
<div class="login-options">
<label><input v-model="remember" type="checkbox" /><span></span>记住登录状态</label>
<button type="button" @click="forgotVisible = true">忘记密码</button>
</div>
<div class="login-error">{{ loginError }}</div>
<button class="login-submit" type="submit" :disabled="loading">登录平台<svg><use href="#i-chevron"/></svg></button>
</form>
<small class="login-help">还没有企业账号<button type="button" class="login-switch" @click="mode = 'register'">注册企业账号</button></small>
</div>
<div class="login-panel" :class="{ active: mode === 'register' }">
<div class="login-form-heading"><span>企业入驻</span><h2>注册账号</h2><p>创建企业账户开始使用低空智控平台</p></div>
<form novalidate @submit.prevent="submitRegister">
<label><span>企业名称</span><div class="login-input"><svg><use href="#i-box"/></svg><input v-model="reg.company" type="text" placeholder="请输入企业全称" /></div></label>
<div class="login-form-row">
<label><span>联系人</span><input v-model="reg.contact" type="text" placeholder="姓名" /></label>
<label><span>联系电话</span><input v-model="reg.phone" type="tel" placeholder="手机号" /></label>
</div>
<label><span>企业邮箱</span><div class="login-input"><svg><use href="#i-user"/></svg><input v-model="reg.email" type="email" placeholder="用于接收平台通知" /></div></label>
<div class="login-form-row">
<label><span>设置密码</span><input v-model="reg.password" type="password" placeholder="至少 6 位" /></label>
<label><span>确认密码</span><input v-model="reg.confirm" type="password" placeholder="再次输入密码" /></label>
</div>
<label class="register-agreement"><input v-model="reg.agreement" type="checkbox" /><span></span>我已阅读并同意平台服务协议隐私政策</label>
<div class="login-error">{{ registerError }}</div>
<button class="login-submit" type="submit" :disabled="loading">提交注册<svg><use href="#i-chevron"/></svg></button>
</form>
<small class="login-help">已有企业账号<button type="button" class="login-switch" @click="mode = 'login'">返回登录</button></small>
</div>
</div>
</div>
<div class="modal-backdrop" :class="{ open: forgotVisible }">
<div class="modal forgot-password-dialog" role="dialog" aria-modal="true">
<header><div><span>账号安全</span><h3>找回登录密码</h3></div><button class="icon-button" type="button" @click="forgotVisible = false"><svg><use href="#i-x"/></svg></button></header>
<form novalidate @submit.prevent="submitForgot">
<label><span>登录账号</span><input v-model="forgot.account" type="text" placeholder="请输入手机号" /></label>
<label><span>绑定手机号</span><input v-model="forgot.phone" type="tel" placeholder="请输入接收验证码的手机号" /></label>
<label><span>短信验证码</span><div class="login-input"><svg><use href="#i-message"/></svg><input v-model="forgot.code" inputmode="numeric" maxlength="6" placeholder="请输入短信验证码" /><button class="send-code-button" type="button" @click="sendForgotCode">获取验证码</button></div></label>
<label><span>设置新密码</span><input v-model="forgot.password" type="password" placeholder="至少 6 位" /></label>
<div class="form-error">{{ forgotError }}</div>
<footer><button class="plain-command" type="button" @click="forgotVisible = false">取消</button><button class="primary-command" type="submit">确认重置</button></footer>
</form>
</div>
</div>
</section>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import request from '../api/request'
import { useUserStore } from '../store/user'
import { useUiStore } from '../store/ui'
const router = useRouter()
const userStore = useUserStore()
const ui = useUiStore()
const mode = ref('login')
const method = ref('password')
const loading = ref(false)
const pwdVisible = ref(false)
const remember = ref(true)
const account = ref('')
const password = ref('')
const smsPhone = ref('')
const smsCode = ref('')
const captcha = ref('')
const captchaValue = ref('7K4P')
const loginError = ref('')
const loginCodeTimer = ref(0)
const reg = reactive({ company: '', contact: '', phone: '', email: '', password: '', confirm: '', agreement: false })
const registerError = ref('')
const forgotVisible = ref(false)
const forgot = reactive({ account: '', phone: '', code: '', password: '' })
const forgotError = ref('')
function refreshCaptcha() {
const chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
captchaValue.value = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
async function submitLogin() {
loginError.value = ''
if (!captcha.value) { loginError.value = '请输入图形验证码。'; return }
if (captcha.value.toUpperCase() !== captchaValue.value) { loginError.value = '图形验证码不正确,请重新输入。'; return }
if (method.value === 'password' && (!account.value || !password.value)) { loginError.value = '请输入账号和密码。'; return }
if (method.value === 'sms') { loginError.value = '短信登录暂未开放,请使用账号密码登录。'; return }
loading.value = true
try {
const data = await request.post('/v1/auth/login', { phone: account.value, password: password.value })
userStore.setLogin(data.accessToken, data.user)
ui.toast('登录成功')
router.push('/monitor')
} catch (e) {
//
} finally {
loading.value = false
}
}
async function submitRegister() {
registerError.value = ''
if (!reg.company || !reg.contact || !reg.phone || !reg.email || !reg.password || !reg.confirm) {
registerError.value = '请完整填写注册信息。'; return
}
if (reg.password.length < 6 || reg.password !== reg.confirm) {
registerError.value = '请确认密码一致且不少于 6 位。'; return
}
if (!reg.agreement) { registerError.value = '请先同意平台服务协议和隐私政策。'; return }
loading.value = true
try {
const data = await request.post('/v1/auth/register', { name: reg.company, phone: reg.phone, email: reg.email, password: reg.password })
if (data && data.accessToken) {
userStore.setLogin(data.accessToken, data.user)
ui.toast('注册成功')
router.push('/monitor')
} else {
mode.value = 'login'
account.value = reg.phone
password.value = ''
ui.toast('注册成功,请登录')
}
} catch (e) {
//
} finally {
loading.value = false
}
}
function sendLoginCode() {
if (!smsPhone.value) { loginError.value = '请先输入手机号。'; return }
if (loginCodeTimer.value) return
loginCodeTimer.value = 60
const timer = setInterval(() => {
loginCodeTimer.value -= 1
if (loginCodeTimer.value <= 0) clearInterval(timer)
}, 1000)
request.post('/v1/auth/sms-code', { phone: smsPhone.value }).catch(() => {})
ui.toast('短信验证码已发送')
}
function sendForgotCode() {
if (!forgot.phone) { forgotError.value = '请先输入手机号。'; return }
request.post('/v1/auth/sms-code', { phone: forgot.phone }).catch(() => {})
ui.toast('短信验证码已发送')
}
async function submitForgot() {
forgotError.value = ''
if (!forgot.account || !forgot.phone || !forgot.code || !forgot.password) { forgotError.value = '请完整填写找回信息。'; return }
if (forgot.password.length < 6) { forgotError.value = '新密码至少需要 6 位。'; return }
try {
await request.post('/v1/auth/reset-password', { phone: forgot.phone, smsCode: forgot.code, newPassword: forgot.password })
forgotVisible.value = false
ui.toast('密码已重置,请使用新密码登录')
} catch (e) {}
}
</script>

145
src/views/Media.vue

@ -0,0 +1,145 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>媒体中心</h1>
<span>统一查看实时直播与原始视频资产</span>
</div>
<div class="page-actions">
<button class="icon-command" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></button>
</div>
</header>
<div class="business-workspace">
<div class="business-tabs">
<button :class="{ active: view === 'live' }" @click="setView('live')">直播中心<b>{{ liveStreams.length }}</b></button>
<button :class="{ active: view === 'videos' }" @click="setView('videos')">原始视频<b>{{ videos.length }}</b></button>
</div>
<!-- 直播中心 -->
<div class="business-panel" :class="{ active: view === 'live' }" style="padding:0">
<div class="media-panel-heading">
<div><h2>实时直播画面</h2><span>机巢与无人机第一视角直播</span></div>
<span class="media-live-count"><i></i>{{ liveStreams.length }} 路直播中</span>
</div>
<div class="live-media-grid">
<article v-for="s in liveStreams" :key="s.id">
<div class="media-visual" :class="s.visualClass">
<span class="live-corner"><i></i>LIVE</span>
<button @click="ui.toast('打开直播:' + s.name)"><svg><use href="#i-play" /></svg></button>
<small>{{ s.source }}</small>
</div>
<div class="media-card-copy">
<span><strong>{{ s.name }}</strong><small>{{ s.desc }}</small></span>
<button class="icon-button" @click="ui.toast('更多操作')"><svg><use href="#i-more" /></svg></button>
</div>
</article>
<div v-if="!liveStreams.length" class="table-empty" style="grid-column:1/-1">暂无直播流</div>
</div>
</div>
<!-- 原始视频 -->
<div class="business-panel" :class="{ active: view === 'videos' }" style="padding:0">
<div class="media-panel-heading original-video-toolbar">
<div><h2>原始视频</h2><span>任务与手动拍摄的原始素材</span></div>
<div class="download-quota">
<span>原始视频资产 <b>{{ videos.length }} </b></span>
</div>
</div>
<div class="media-grid">
<article v-for="v in videos" :key="v.id">
<div class="media-visual" :class="v.visualClass">
<button @click="ui.toast('播放:' + v.name)"><svg><use href="#i-play" /></svg></button>
<small class="media-duration">{{ v.duration }}</small>
</div>
<div class="media-card-copy">
<span><strong>{{ v.name }}</strong><small>{{ v.desc }}</small></span>
<button class="icon-button" @click="download(v)"><svg><use href="#i-download" /></svg></button>
</div>
</article>
<div v-if="!videos.length" class="table-empty" style="grid-column:1/-1">暂无视频资产</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const route = useRoute()
const router = useRouter()
const ui = useUiStore()
const view = ref(route.query.view || 'videos')
watch(
() => route.query.view,
(v) => { if (v) view.value = v }
)
function setView(v) {
router.push({ name: 'media', query: { view: v } })
}
const liveStreams = ref([])
const videos = ref([])
function fmtDuration(sec) {
if (!sec && sec !== 0) return '--:--'
const s = Number(sec)
const m = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${m}:${ss}`
}
function fmtSize(bytes) {
if (!bytes) return '--'
const mb = bytes / 1024 / 1024
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`
}
async function load() {
try {
const [livePage, videoPage] = await Promise.all([
request.get('/v1/live', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/videos', { params: { pageNum: 1, pageSize: 100 } })
])
liveStreams.value = (livePage?.records || []).map((s) => ({
id: s.id,
name: s.dockId,
desc: s.streamName ? `${s.streamName} · ${s.provider}` : s.provider,
source: s.streamName || '--',
visualClass: 'dock-camera'
}))
videos.value = (videoPage?.records || []).map((v, i) => ({
id: String(v.id),
name: v.fileName || '未命名视频',
desc: `${v.droneSn || '未知设备'} · ${fmtSize(v.fileSize)}`,
duration: fmtDuration(v.duration),
visualClass: i % 2 === 0 ? 'aerial-1' : 'aerial-2'
}))
} catch (e) {
ui.toast(e.message || '加载媒体数据失败')
}
}
onMounted(load)
async function download(v) {
try {
const res = await request.post(`/v1/videos/${v.id}/download`)
if (res?.downloadUrl) {
window.open(res.downloadUrl, '_blank')
ui.toast('开始下载:' + v.name)
} else {
ui.toast('暂无下载地址')
}
} catch (e) {
ui.toast(e.message || '下载失败')
}
}
</script>

479
src/views/Monitor.vue

@ -0,0 +1,479 @@
<template>
<section class="monitor-layout" :class="{ focused: !!selectedId, 'control-expanded': controlExpanded }">
<aside class="device-panel">
<div class="panel-heading">
<div><h1>设备列表</h1><span>{{ devices.docks.length }} · {{ devices.drones.length }} </span></div>
<button class="icon-button" title="收起面板" @click="clearSelection"><svg><use href="#i-x" /></svg></button>
</div>
<label class="device-search">
<svg><use href="#i-search" /></svg>
<input v-model="search" type="text" placeholder="搜索机巢或无人机" />
</label>
<div class="filter-row">
<button class="filter" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部 <b>{{ filterCounts.all }}</b></button>
<button class="filter" :class="{ active: filter === 'online' }" @click="filter = 'online'">在线 <b>{{ filterCounts.online }}</b></button>
<button class="filter" :class="{ active: filter === 'alarm' }" @click="filter = 'alarm'">告警 <b>{{ filterCounts.alarm }}</b></button>
</div>
<div v-if="filteredDocks.length" class="multi-select-bar" :class="{ 'has-selection': selectedDockIds.size }">
<span>已选择 {{ selectedDockIds.size }} 个机巢</span>
<button type="button" @click="selectAllDocks">全选</button>
<button type="button" @click="clearDockSelection">取消选择</button>
</div>
<div class="device-list">
<template v-for="dock in filteredDocks" :key="dock.id">
<article class="device-card" :class="{ active: isDockCardActive(dock), 'multi-selected': selectedDockIds.has(dock.id) }">
<label class="device-select-checkbox" :title="`选择${dock.name}`" @click.stop>
<input type="checkbox" :checked="selectedDockIds.has(dock.id)" @change="toggleDockSelection(dock.id)" />
<span></span>
</label>
<button class="device-card-main" @click="select(dock.id)">
<span class="dock-glyph" :class="{ alarm: dock.statusClass === 'alarm', offline: dock.statusClass === 'offline' }">
<svg><use href="#i-home" /></svg>
</span>
<span class="device-copy">
<strong>{{ dock.name }}</strong>
<small>{{ dock.code }}</small>
</span>
<i class="status" :class="dock.statusClass">{{ dock.status }}</i>
</button>
<button
v-for="drone in dock.children"
:key="drone.id"
class="bound-drone"
:class="{ active: selectedId === drone.id, flying: drone.statusClass === 'mission', offline: drone.statusClass === 'offline' }"
@click="select(drone.id)"
>
<span class="tree-line"></span>
<svg><use href="#i-plane" /></svg>
<span><strong>{{ drone.name }}</strong><small>{{ droneSummary(drone) }}</small></span>
<i>{{ droneTag(drone) }}</i>
</button>
<div v-if="dock.statusClass === 'alarm'" class="device-alert">
<svg><use href="#i-alert" /></svg>检测到设备告警<span>{{ dock.updated }}</span>
</div>
<div v-else-if="dock.statusClass === 'offline'" class="device-alert muted">
最后在线 {{ dock.updated }}<span>网络中断</span>
</div>
</article>
</template>
<div v-if="!filteredDocks.length" class="table-empty" style="height:120px;display:grid;place-items:center;color:var(--muted);font-size:10px">暂无匹配设备</div>
</div>
</aside>
<div class="map-panel">
<div class="map-world" :style="mapStyle" @click="handleMapClick">
<div class="map-surface"></div>
<svg v-if="bindingLine" class="binding-line" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line :x1="bindingLine.x1" :y1="bindingLine.y1" :x2="bindingLine.x2" :y2="bindingLine.y2" />
<text :x="(bindingLine.x1 + bindingLine.x2) / 2" :y="(bindingLine.y1 + bindingLine.y2) / 2 - 2">绑定机巢</text>
</svg>
<button
v-for="a in mapMarkers"
:key="a.id"
class="map-marker"
:class="[a.statusClass, { selected: selectedId === a.id }]"
:style="{ left: a.x + '%', top: a.y + '%' }"
@click.stop="select(a.id)"
>
<span><svg><use :href="a.type === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
<b>{{ a.name }}</b>
<small v-if="a.type === 'drone' && a.altitude != null">{{ a.altitude }} m</small>
<em v-if="a.type === 'drone' && a.statusClass === 'mission'"><svg><use href="#i-zap" /></svg></em>
</button>
</div>
<div class="map-toolbar">
<span class="map-type active">静态底图</span>
<button class="icon-button" title="放大" @click="zoomIn"><svg><use href="#i-plus" /></svg></button>
<button class="icon-button" title="缩小" @click="zoomOut"><svg><use href="#i-minus" /></svg></button>
<button class="icon-button" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button>
<button class="map-view-all" type="button" @click="clearSelection">查看全部设备</button>
</div>
<div class="map-legend">
<span><i class="green"></i>在线机巢</span>
<span><i class="blue"></i>飞行中</span>
<span><i class="red"></i>告警</span>
<span><i class="gray"></i>离线</span>
</div>
</div>
<aside class="detail-panel" :class="{ 'drone-selected': isDrone }">
<template v-if="selectedAsset">
<header class="detail-header">
<div>
<span class="eyebrow">{{ isDrone ? '巡检无人机' : '机巢设备' }}</span>
<h2>{{ selectedAsset.name }}</h2>
<p><svg><use href="#i-map-pin" /></svg>{{ selectedAsset.location }}</p>
</div>
<div class="detail-head-actions">
<i class="status" :class="selectedAsset.statusClass">{{ selectedAsset.status }}</i>
<button class="icon-button" title="查看设备详情" @click="goDetail"><svg><use href="#i-maximize" /></svg></button>
<button class="icon-button" title="关闭详情" @click="clearSelection"><svg><use href="#i-x" /></svg></button>
</div>
</header>
<!-- Drone only: bound dock -->
<div class="asset-relation" v-if="isDrone">
<span><svg><use href="#i-link" /></svg>绑定机巢{{ selectedDroneParentName }}</span>
<button v-if="selectedAsset.parent" @click="select(selectedAsset.parent)">切换<svg><use href="#i-chevron" /></svg></button>
</div>
<!-- Dock only: live video -->
<section v-if="!isDrone" class="video-section">
<div class="section-title">
<h3>实时画面</h3>
<span class="video-unavailable">暂无视频流</span>
</div>
<div class="video-frame video-empty">
<span>暂无实时视频数据</span>
</div>
</section>
<!-- Dock only: status metrics -->
<section v-if="!isDrone" class="status-section">
<div class="section-title"><h3>机巢状态</h3><span>更新于 {{ record?.updated || '刚刚' }}</span></div>
<div class="metric-grid">
<div><svg><use href="#i-battery" /></svg><span>无人机电量</span><strong>{{ selectedDrone?.battery || '--' }}</strong></div>
<div><svg><use href="#i-wind" /></svg><span>舱内温度</span><strong>{{ displayWithUnit(selectedAsset.environment?.insideTemperature, '℃') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>舱内湿度</span><strong>{{ displayWithUnit(selectedAsset.environment?.insideHumidity, '%') }}</strong></div>
<div><svg><use href="#i-satellite" /></svg><span>PLC连接</span><strong>{{ boolText(selectedAsset.realtime?.plcConnected) }}</strong></div>
</div>
</section>
<!-- Drone only: flight status -->
<section v-if="isDrone" class="status-section">
<div class="section-title"><h3>飞行状态</h3><span>更新于 {{ record?.updated || '刚刚' }}</span></div>
<div class="metric-grid">
<div><svg><use href="#i-battery" /></svg><span>剩余电量</span><strong>{{ record?.battery || '--' }}</strong></div>
<div><svg><use href="#i-plane" /></svg><span>飞行高度</span><strong>{{ displayWithUnit(selectedAsset.altitude, 'm') }}</strong></div>
<div><svg><use href="#i-wind" /></svg><span>飞行速度</span><strong>{{ displayWithUnit(selectedAsset.speed, 'm/s') }}</strong></div>
<div><svg><use href="#i-satellite" /></svg><span>卫星数量</span><strong>{{ displayWithUnit(selectedAsset.satellites, '颗') }}</strong></div>
</div>
</section>
<!-- Drone only: mission -->
<section class="environment-section">
<div class="section-title"><h3>环境信息</h3></div>
<div v-if="hasEnvironment" class="environment-row">
<div><svg><use href="#i-wind" /></svg><span>风速</span><strong>{{ displayWithUnit(selectedDockEnvironment?.windSpeed, 'm/s') }}</strong></div>
<div><svg><use href="#i-sun" /></svg><span>室外温度</span><strong>{{ displayWithUnit(selectedDockEnvironment?.outsideTemperature, '℃') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>降雨</span><strong>{{ boolText(selectedDockEnvironment?.rain) }}</strong></div>
</div>
<div v-else class="table-empty">暂无环境数据</div>
</section>
<!-- Quick actions -->
<section v-if="!isAdmin" class="quick-actions" :class="{ 'drone-quick-actions': isDrone, 'all-controls': controlExpanded }">
<template v-if="!isDrone">
<div class="section-title">
<h3>{{ controlExpanded ? '控制' : '快捷控制' }}</h3>
<button class="control-mode-toggle" :class="{ expanded: controlExpanded }" type="button" @click="controlExpanded = !controlExpanded">
{{ controlExpanded ? '简单控制' : '更多控制' }}<svg><use href="#i-chevron" /></svg>
</button>
</div>
<div class="workflow-actions">
<button class="workflow-action takeoff" @click="runCommand('一键起飞', '无人机将从机巢起飞并进入待命状态。')"><svg><use href="#i-plane" /></svg><span><strong>一键起飞</strong><small>状态待执行</small></span></button>
<button class="workflow-action landing" @click="runCommand('一键降落', '无人机将返回机巢并自动降落。')"><svg><use href="#i-home" /></svg><span><strong>一键降落</strong><small>状态待执行</small></span></button>
</div>
<div class="stage-actions">
<button @click="runCommand('起飞准备', '将执行开舱并解除归中。')"><b>01</b><span>起飞准备<small>状态待执行</small></span></button>
<button @click="runCommand('起飞准备完成', '确认起飞准备已完成。')"><b>02</b><span>起飞准备完成<small>状态待执行</small></span></button>
<button @click="runCommand('降落准备', '将开舱并等待无人机返航。')"><b>03</b><span>降落准备<small>状态待执行</small></span></button>
<button @click="runCommand('降落准备完成', '确认降落准备已完成。')"><b>04</b><span>降落准备完成<small>状态待执行</small></span></button>
</div>
<button class="reset-action" @click="runCommand('整机复位', '机巢将恢复待命状态。')"><svg><use href="#i-settings" /></svg><span><strong>整机复位</strong><small>状态待执行</small></span></button>
<div v-show="controlExpanded" class="dock-control-extra">
<div class="dock-control-extra-grid">
<button v-for="cmd in dockExtraCommands" :key="cmd.title" @click="runCommand(cmd.title, cmd.desc)">
<svg><use :href="cmd.icon" /></svg>
<span><strong>{{ cmd.title }}</strong><small>{{ cmd.hint }}</small></span>
</button>
</div>
</div>
</template>
<template v-else>
<div class="section-title">
<h3>{{ controlExpanded ? '控制' : '快捷控制' }}</h3>
<button class="control-mode-toggle" :class="{ expanded: controlExpanded }" type="button" @click="controlExpanded = !controlExpanded">
{{ controlExpanded ? '简单控制' : '更多控制' }}<svg><use href="#i-chevron" /></svg>
</button>
</div>
<button
v-if="isFlying"
class="primary-action return-action"
@click="runCommand('一键返航', '无人机将返回绑定机巢并自动降落。')"
>
<svg><use href="#i-home" /></svg>
<span><strong>一键返航</strong><small>自动回巢 · 降落</small></span>
</button>
<button
v-else
class="primary-action"
@click="runCommand('起飞', '无人机将立即起飞进入待命状态。')"
>
<svg><use href="#i-plane" /></svg>
<span><strong>起飞</strong><small>离巢后进入待命</small></span>
</button>
<div class="secondary-actions">
<button @click="runCommand('暂停任务', '向绑定机巢下发暂停指令。')"><svg><use href="#i-stop" /></svg>暂停任务</button>
<button @click="runCommand('继续任务', '向绑定机巢下发继续指令。')"><svg><use href="#i-play" /></svg>继续任务</button>
<button @click="runCommand('取消任务', '向绑定机巢下发取消指令。')"><svg><use href="#i-x" /></svg>取消任务</button>
</div>
<button class="reset-action" @click="runCommand('紧急停止', '立即中止飞行,无人机原地悬停待命。')"><svg><use href="#i-stop" /></svg><span><strong>紧急停止</strong><small>中止任务 · 原地悬停</small></span></button>
<div v-show="controlExpanded" class="drone-control-all">
<button v-for="cmd in droneExtraCommands" :key="cmd.title" :class="{ danger: cmd.danger }" @click="runCommand(cmd.title, cmd.desc)">
<svg><use :href="cmd.icon" /></svg>
<span><strong>{{ cmd.title }}</strong><small>{{ cmd.hint }}</small></span>
</button>
</div>
</template>
</section>
</template>
<div v-else class="table-empty" style="height:100%;display:grid;place-items:center;color:var(--muted);font-size:11px">请在左侧选择设备</div>
</aside>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useDeviceStore } from '../store/devices'
import { useUiStore } from '../store/ui'
import { useRouter } from 'vue-router'
import { useUserStore } from '../store/user'
const devices = useDeviceStore()
const ui = useUiStore()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const selectedId = ref(null)
const filter = ref('all')
const search = ref('')
const controlExpanded = ref(false)
const selectedDockIds = reactive(new Set())
const mapZoom = ref(1)
const dockExtraCommands = [
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' },
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' },
{ title: '起飞准备', desc: '将执行开舱并解除归中。', icon: '#i-plane', hint: '下发指令' },
{ title: '降落准备', desc: '将开舱并等待无人机返航。', icon: '#i-home', hint: '下发指令' },
{ title: '打开舱门', desc: '将打开机巢舱门。', icon: '#i-door', hint: '下发指令' },
{ title: '关闭舱门', desc: '将关闭机巢舱门。', icon: '#i-door', hint: '下发指令' },
{ title: '开始充电', desc: '将连接充电回路。', icon: '#i-zap', hint: '下发指令' },
{ title: '无人机开机', desc: '将开启舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
{ title: '无人机关机', desc: '将关闭舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
{ title: '整机复位', desc: '机巢将恢复待命状态。', icon: '#i-settings', hint: '下发指令' },
{ title: '远程重启', desc: '设备将短暂离线后重新连接。', icon: '#i-settings', hint: '下发指令' },
{ title: '清除设备告警', desc: '将清除机巢当前未处理告警。', icon: '#i-check', hint: '下发指令' }
]
const droneExtraCommands = [
{ title: '起飞', desc: '无人机将立即起飞进入待命状态。', icon: '#i-plane', hint: '离巢待命' },
{ title: '降落', desc: '无人机将就地降落。', icon: '#i-home', hint: '就地降落' },
{ title: '一键返航', desc: '无人机将返回绑定机巢并自动降落。', icon: '#i-home', hint: '自动回巢' },
{ title: '悬停', desc: '无人机将在当前位置悬停。', icon: '#i-stop', hint: '保持位置' },
{ title: '开始任务', desc: '向绑定机巢下发开始任务指令。', icon: '#i-play', hint: '下发指令' },
{ title: '暂停任务', desc: '向绑定机巢下发暂停指令。', icon: '#i-stop', hint: '下发指令' },
{ title: '继续任务', desc: '向绑定机巢下发继续指令。', icon: '#i-play', hint: '下发指令' },
{ title: '取消任务', desc: '向绑定机巢下发取消指令。', icon: '#i-x', hint: '下发指令' },
{ title: '紧急停止', desc: '立即中止飞行,无人机原地悬停待命。', icon: '#i-stop', hint: '原地悬停', danger: true }
]
onMounted(async () => {
try {
await devices.load()
if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId)
const rec = selectedId.value ? devices.record(selectedId.value) : null
controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock'
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
})
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null))
const isDrone = computed(() => selectedAsset.value?.type === 'drone')
const record = computed(() => (selectedId.value ? devices.record(selectedId.value) : null))
const isFlying = computed(() => isDrone.value && record.value?.statusClass === 'mission')
const selectedDrone = computed(() => (isDrone.value ? record.value : devices.drones.find((d) => d.dockId === record.value?.dockId) || null))
const selectedDock = computed(() => (isDrone.value ? devices.docks.find((d) => d.dockId === record.value?.dockId) || null : record.value))
const selectedDockEnvironment = computed(() => selectedDock.value?.environment || null)
const hasEnvironment = computed(() => Object.values(selectedDockEnvironment.value || {}).some((value) => value != null && value !== ''))
function displayWithUnit(value, unit) {
return value == null || value === '' ? '--' : `${value}${unit}`
}
function boolText(value) {
if (value === true || value === 'true' || value === '1' || value === 1) return '是'
if (value === false || value === 'false' || value === '0' || value === 0) return '否'
return '--'
}
async function reload() {
try {
await devices.load()
if (selectedId.value && !devices.record(selectedId.value)) clearSelection()
selectedDockIds.forEach((id) => {
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id)
})
ui.toast('设备状态已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备状态失败')
}
}
const groupedDocks = computed(() =>
devices.docks.map((dock) => ({
...dock,
children: devices.drones.filter((d) => d.dockId === dock.dockId)
}))
)
const filteredDocks = computed(() => {
const q = search.value.trim().toLowerCase()
return groupedDocks.value.filter((dock) => {
const matchesFilter =
filter.value === 'all' ||
(filter.value === 'online' && dock.statusClass === 'online') ||
(filter.value === 'alarm' && dock.statusClass === 'alarm')
if (!matchesFilter) return false
if (!q) return true
return (
dock.name.toLowerCase().includes(q) ||
dock.code.toLowerCase().includes(q) ||
dock.location.toLowerCase().includes(q) ||
dock.children.some((c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
)
})
})
const filterCounts = computed(() => ({
all: devices.docks.length,
online: devices.docks.filter((d) => d.statusClass === 'online').length,
alarm: devices.docks.filter((d) => d.statusClass === 'alarm').length
}))
const mapMarkers = computed(() => Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock))
const selectedDroneParentName = computed(() => {
if (!isDrone.value) return ''
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定'
})
const bindingLine = computed(() => {
if (!isDrone.value || !selectedAsset.value?.hasCoordinates || !selectedAsset.value?.parent) return null
const dock = devices.asset(selectedAsset.value.parent)
if (!dock?.hasCoordinates) return null
return { x1: dock.x, y1: dock.y, x2: selectedAsset.value.x, y2: selectedAsset.value.y }
})
const mapStyle = computed(() => {
const a = selectedAsset.value
if (!a || a.x == null) return { transform: `scale(${mapZoom.value})` }
return {
transform: `scale(${mapZoom.value * 1.34})`,
transformOrigin: `${a.x}% ${a.y}%`
}
})
function select(id) {
if (!id) return
const prev = selectedId.value ? devices.record(selectedId.value) : null
selectedId.value = String(id)
const rec = devices.record(id)
if (!rec || rec.kind !== prev?.kind) controlExpanded.value = false
}
function clearDockSelection() {
selectedDockIds.clear()
}
function toggleDockSelection(id) {
if (selectedDockIds.has(id)) selectedDockIds.delete(id)
else selectedDockIds.add(id)
}
function selectAllDocks() {
filteredDocks.value.forEach((dock) => selectedDockIds.add(dock.id))
}
function clearSelection() {
selectedId.value = null
controlExpanded.value = false
mapZoom.value = 1
}
function handleMapClick(event) {
if (event.target === event.currentTarget || event.target.classList.contains('map-surface')) clearSelection()
}
function isDockCardActive(dock) {
return selectedId.value === dock.id || dock.children.some((c) => c.id === selectedId.value)
}
function droneSummary(drone) {
if (drone.statusClass === 'mission') {
const alt = devices.asset(drone.id)?.altitude
if (alt != null) return `飞行中 · ${alt} m`
return '飞行中'
}
if (drone.statusClass === 'offline') return '设备离线'
return drone.battery && drone.battery !== '--' ? `已入巢 · 充电 ${drone.battery}` : '已入巢'
}
function droneTag(drone) {
if (drone.statusClass === 'mission') return '飞行中'
if (drone.statusClass === 'offline') return '离线'
return '在巢'
}
function num(v) {
if (v == null) return '--'
return String(v).replace('%', '')
}
function goDetail() {
router.push({ name: 'device-detail', params: { id: selectedId.value } })
}
function zoomIn() {
mapZoom.value = Math.min(2, Math.round((mapZoom.value + 0.15) * 100) / 100)
}
function zoomOut() {
mapZoom.value = Math.max(0.7, Math.round((mapZoom.value - 0.15) * 100) / 100)
}
async function runCommand(title, desc) {
const rec = record.value
if (!rec) return
if (!rec.online) {
ui.toast('设备当前离线,无法下发指令')
return
}
if (rec.kind === 'drone' && rec.bindingState !== 'bound') {
ui.toast('无人机未绑定机巢,无法下发指令')
return
}
const ok = await ui.confirm(title, desc)
if (!ok) return
try {
await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`)
await reload()
} catch (e) {
ui.toast(e.message || '指令下发失败')
}
}
</script>

159
src/views/OperationLogs.vue

@ -0,0 +1,159 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>设备操作记录</h1>
<span>你对机巢和无人机下发的控制指令</span>
</div>
<button class="icon-command" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></button>
</header>
<div class="business-workspace">
<div class="asset-toolbar">
<div class="asset-search"><svg><use href="#i-search" /></svg><input v-model="keyword" type="text" placeholder="搜索机巢 / 指令 / 无人机" @keyup.enter="reload" /></div>
<div class="toolbar-group">
<select v-model="statusFilter" @change="reload">
<option value="">全部状态</option>
<option value="sent">已下发</option>
<option value="acked">已确认</option>
<option value="timeout">已超时</option>
<option value="terminal">执行失败</option>
</select>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr>
<th style="width:18%">目标机巢</th>
<th style="width:20%">控制指令</th>
<th style="width:18%">目标飞机</th>
<th style="width:12%">执行状态</th>
<th style="width:14%">应答结果</th>
<th style="width:18%">下发时间</th>
</tr>
</thead>
<tbody>
<tr v-for="c in list" :key="c.id">
<td>{{ c.dockId }}</td>
<td><strong>{{ c.commandName }}</strong><small>{{ c.commandType }}</small></td>
<td>{{ c.droneSn }}</td>
<td><span class="table-status" :class="c.statusClass"><i></i>{{ c.statusName }}</span></td>
<td>{{ c.result }}</td>
<td>{{ c.createdAt }}</td>
</tr>
</tbody>
</table>
<div v-if="!list.length" class="table-empty">暂无设备操作记录</div>
</div>
<div class="table-footer">
<span> {{ total }} 条记录 · {{ pageNum }} / {{ pages }} </span>
<div>
<button @click="goPage(pageNum - 1)"><svg><use href="#i-chevron" /></svg></button>
<b>{{ pageNum }}</b>
<button @click="goPage(pageNum + 1)"><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const ui = useUiStore()
const list = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(20)
const pages = ref(1)
const keyword = ref('')
const statusFilter = ref('')
const COMMAND_NAMES = {
'dock.open': '打开舱门',
'dock.close': '关闭舱门',
'dock.reset': '整机复位',
'dock.prepare_takeoff': '起飞准备',
'dock.complete_takeoff': '起飞准备完成',
'dock.prepare_landing': '降落准备',
'dock.complete_landing': '降落准备完成',
'dock.charge_start': '开始充电',
'dock.drone_power_on': '无人机开机',
'dock.drone_power_off': '无人机关机',
'dock.reboot': '远程重启',
'dock.clear_alarm': '清除设备告警',
'dock.emergency_stop': '紧急停止',
'drone.takeoff': '无人机起飞',
'drone.land': '无人机降落',
'drone.return': '一键返航',
'drone.mission_upload': '上传任务',
'drone.mission_start': '开始任务',
'drone.mission_pause': '暂停任务',
'drone.mission_resume': '恢复任务',
'drone.mission_cancel': '取消任务',
'workflow.start_task': '执行任务',
'workflow.one_key_takeoff': '一键起飞',
'workflow.one_key_land': '一键降落',
'video.start_stream': '开始直播',
'video.stop_stream': '停止直播'
}
const STATUS_NAMES = {
sent: { name: '已下发', cls: 'processing' },
acked: { name: '已确认', cls: 'online' },
timeout: { name: '已超时', cls: 'offline' },
terminal: { name: '执行失败', cls: 'offline' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const page = await request.get('/v1/commands', { params })
total.value = page?.total || 0
pages.value = page?.pages || 1
list.value = (page?.records || []).map((c) => {
const status = STATUS_NAMES[c.status] || { name: c.status || '--', cls: 'offline' }
return {
id: String(c.id),
dockId: c.dockId || '--',
commandType: c.commandType || '--',
commandName: COMMAND_NAMES[c.commandType] || c.commandType || '--',
droneSn: c.droneSn || '机巢本体',
statusName: status.name,
statusClass: status.cls,
result: c.ackResultCode || (c.status === 'sent' ? '等待应答' : '--'),
createdAt: fmtTime(c.createdAt)
}
})
} catch (e) {
ui.toast(e.message || '加载设备操作记录失败')
}
}
function reload() {
pageNum.value = 1
load()
}
function goPage(p) {
if (p < 1 || p > pages.value) return
pageNum.value = p
load()
}
onMounted(load)
</script>

127
src/views/Replay.vue

@ -0,0 +1,127 @@
<template>
<section class="task-replay-page">
<header class="replay-header">
<div class="replay-header-summary">
<button class="back-command" @click="router.push({ name: 'tasks', query: { view: 'records' } })"><svg><use href="#i-chevron" /></svg>返回执行记录</button>
</div>
<div>
<small>执行回放</small>
<h1>{{ recordName }}</h1>
<span>设备 <b>{{ deviceName }}</b> · 航线 <b>{{ routeName }}</b></span>
</div>
<div class="replay-header-summary">
<span>飞行时长<strong>{{ durationLabel }}</strong></span>
<span>轨迹点<strong>{{ points.length }}</strong></span>
</div>
</header>
<div class="replay-map">
<div class="replay-map-base"></div>
<template v-if="points.length">
<svg class="replay-route-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
<polyline class="replay-route-shadow" :points="polyline" />
<polyline class="replay-route-line" :points="polyline" />
</svg>
<svg class="replay-route-layer replay-waypoints" viewBox="0 0 100 100" preserveAspectRatio="none">
<circle v-for="(p, i) in waypointDots" :key="i" :cx="p.x" :cy="p.y" :r="i === 0 || i === waypointDots.length - 1 ? 1.4 : 1.1" />
</svg>
</template>
<div v-else class="table-empty replay-empty">该执行记录暂无轨迹数据</div>
<button class="replay-map-back" @click="router.push({ name: 'tasks', query: { view: 'records' } })"><svg><use href="#i-chevron" /></svg>返回</button>
<div class="replay-map-legend">
<span><i class="route-start"></i>起点</span>
<span><i class="route-end"></i>终点</span>
<span><i></i>飞行航线</span>
</div>
<div class="replay-telemetry">
<header><span>遥测数据</span><b>轨迹回放</b></header>
<div class="telemetry-grid">
<div><span>平均高度</span><strong>{{ avgAltitude }} m</strong></div>
<div><span>平均速度</span><strong>{{ avgSpeed }} m/s</strong></div>
<div><span>终点电量</span><strong>{{ lastBattery }}%</strong></div>
</div>
<dl>
<div><dt>轨迹点</dt><dd>{{ points.length }} </dd></div>
<div><dt>开始时间</dt><dd>{{ startLabel }}</dd></div>
</dl>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const route = useRoute()
const router = useRouter()
const ui = useUiStore()
const execution = ref(null)
const points = ref([])
const recordName = computed(() => execution.value?.taskId || '执行记录')
const deviceName = computed(() => execution.value?.droneSn || execution.value?.dockId || '--')
const routeName = computed(() => execution.value?.dockId || '--')
const startLabel = computed(() => {
const t = execution.value?.startTime
if (!t) return '--'
const d = new Date(t)
return Number.isNaN(d.getTime()) ? '--' : d.toLocaleString('zh-CN', { hour12: false })
})
const durationLabel = computed(() => {
const s = execution.value?.startTime
const e = execution.value?.endTime
if (!s || !e) return '--'
const min = Math.round((new Date(e) - new Date(s)) / 60000)
return `${min} min`
})
function project(lon, lat, bounds) {
const lonSpan = bounds.maxLon - bounds.minLon || 1
const latSpan = bounds.maxLat - bounds.minLat || 1
const x = ((lon - bounds.minLon) / lonSpan) * 88 + 6
const y = ((bounds.maxLat - lat) / latSpan) * 88 + 6
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
const waypointDots = computed(() => {
if (!points.value.length) return []
const lons = points.value.map((p) => p.longitude)
const lats = points.value.map((p) => p.latitude)
const bounds = { minLon: Math.min(...lons), maxLon: Math.max(...lons), minLat: Math.min(...lats), maxLat: Math.max(...lats) }
return points.value.map((p) => project(p.longitude, p.latitude, bounds))
})
const polyline = computed(() => waypointDots.value.map((p) => `${p.x},${p.y}`).join(' '))
const avgAltitude = computed(() => {
if (!points.value.length) return '--'
return (points.value.reduce((s, p) => s + (p.altitude || 0), 0) / points.value.length).toFixed(1)
})
const avgSpeed = computed(() => {
if (!points.value.length) return '--'
return (points.value.reduce((s, p) => s + (p.groundSpeed || 0), 0) / points.value.length).toFixed(1)
})
const lastBattery = computed(() => {
if (!points.value.length) return '--'
return points.value[points.value.length - 1].batteryPct ?? '--'
})
onMounted(async () => {
try {
const [ex, traj] = await Promise.all([
request.get(`/v1/executions/${route.params.key}`),
request.get(`/v1/executions/${route.params.key}/trajectory`)
])
execution.value = ex
points.value = traj?.points || []
} catch (e) {
ui.toast(e.message || '加载回放数据失败')
}
})
</script>

88
src/views/System.vue

@ -0,0 +1,88 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>系统管理</h1>
<span>配置平台参数与服务状态</span>
</div>
</header>
<div class="business-workspace system-workspace">
<div class="business-tabs">
<button :class="{ active: tab === 'general' }" @click="tab = 'general'">基础设置</button>
<button :class="{ active: tab === 'service' }" @click="tab = 'service'">服务状态</button>
</div>
<!-- 基础设置 -->
<div class="system-panel" :class="{ active: tab === 'general' }">
<div class="system-grid">
<section class="settings-section">
<div class="settings-heading"><div><h2>企业账号</h2><span>平台管理员账号信息</span></div></div>
<div class="account-profile">
<span>{{ adminInitial }}</span>
<div><strong>{{ adminName }}</strong><small>{{ adminEmail }}</small></div>
<button class="table-text-action" @click="ui.toast('编辑管理员')">编辑</button>
</div>
<div class="settings-list">
<div><span>登录账号</span><strong>{{ adminPhone }}</strong><button @click="ui.toast('修改账号')">修改</button></div>
<div><span>所属企业</span><strong>嘉谷科技</strong></div>
<div><span>账号角色</span><strong>{{ adminRole }}</strong></div>
</div>
</section>
<section class="settings-section">
<div class="settings-heading"><div><h2>平台参数</h2><span>通用配置项</span></div></div>
<div class="settings-list">
<div><span>默认时区</span><strong>Asia/Shanghai (UTC+8)</strong><button @click="ui.toast('修改时区')">修改</button></div>
<div><span>地图服务</span><strong>内置离线地图</strong><button @click="ui.toast('切换地图')">切换</button></div>
<div><span>数据保留</span><strong>90 </strong><button @click="ui.toast('修改保留期')">修改</button></div>
<div><span>语言</span><strong>简体中文</strong></div>
</div>
</section>
</div>
</div>
<!-- 服务状态 -->
<div class="system-panel" :class="{ active: tab === 'service' }">
<div class="system-grid">
<section class="settings-section">
<div class="settings-heading"><div><h2>服务运行状态</h2><span>核心服务健康检查</span></div></div>
<div class="service-status-list">
<div><span><i :class="{ up: backendUp }"></i>后端服务</span><strong>{{ backendUp ? '正常' : '异常' }}</strong></div>
</div>
</section>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useUiStore } from '../store/ui'
import { useUserStore } from '../store/user'
import request from '../api/request'
const ui = useUiStore()
const userStore = useUserStore()
const tab = ref('general')
const backendUp = ref(false)
const adminName = computed(() => userStore.user?.name || '平台管理员')
const adminPhone = computed(() => userStore.user?.phone || '--')
const adminEmail = computed(() => userStore.user?.email || '--')
const adminRole = computed(() => (userStore.isAdmin() ? '超级管理员' : '成员'))
const adminInitial = computed(() => adminName.value.charAt(0))
async function load() {
try {
await request.get('/health')
backendUp.value = true
} catch (e) {
backendUp.value = false
}
}
onMounted(load)
</script>

582
src/views/Tasks.vue

@ -0,0 +1,582 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>任务管理</h1>
<span>今日 {{ todayTaskCount }} 个计划 · {{ runningTaskCount }} 个执行中</span>
</div>
<button class="primary-command" @click="openCreateTask"><svg><use href="#i-plus" /></svg>新建任务</button>
</header>
<div class="business-metric-band task-metrics">
<div><span>今日任务</span><strong>{{ todayTaskCount }}</strong><small>全部计划</small></div>
<div><span>执行中</span><strong class="blue-text">{{ runningTaskCount }}</strong><small>当前执行任务</small></div>
<div><span>待执行</span><strong>{{ pendingTaskCount }}</strong><small>等待执行</small></div>
<div><span>今日完成</span><strong class="green-text">{{ completedTodayCount }}</strong><small>执行成功</small></div>
</div>
<div class="business-workspace">
<div class="business-tabs">
<button :class="{ active: view === 'plans' }" @click="setView('plans')">任务计划<b>{{ plans.length }}</b></button>
<button :class="{ active: view === 'records' }" @click="setView('records')">执行记录<b>{{ records.length }}</b></button>
<button :class="{ active: view === 'routes' }" @click="setView('routes')">航线库<b>{{ routes.length }}</b></button>
</div>
<!-- 任务计划 -->
<div class="business-panel" :class="{ active: view === 'plans' }">
<div class="business-toolbar">
<label class="business-search"><svg><use href="#i-search" /></svg><input v-model="search" type="text" placeholder="搜索任务名称、编号或设备" /></label>
<div>
<select v-model="planFilter">
<option value="">全部状态</option>
<option value="mission">执行中</option>
<option value="pending">待执行</option>
<option value="offline">已暂停</option>
</select>
<button class="plain-command" @click="resetPlanFilter">重置</button>
</div>
</div>
<div class="business-table-wrap">
<table class="business-table task-plan-table">
<thead>
<tr>
<th>任务名称</th>
<th>执行设备</th>
<th>执行方式</th>
<th>下次执行</th>
<th>视频策略</th>
<th>任务状态</th>
<th>最近结果</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredPlans" :key="t.id">
<td><div class="primary-table-cell"><span class="task-table-icon" :class="{ muted: t.statusClass === 'offline' }"><svg><use href="#i-route" /></svg></span><span><strong>{{ t.name }}</strong><small>{{ t.code }} · {{ t.route }}</small></span></div></td>
<td>{{ t.device }}</td>
<td>{{ t.cycle }}</td>
<td :class="{ 'blue-text': t.statusClass === 'mission' }">{{ t.next }}</td>
<td><span class="media-policy" :class="t.videoPolicyClass">{{ t.videoPolicy }}</span></td>
<td><span class="table-status" :class="t.statusClass"><i></i>{{ t.status }}</span></td>
<td>{{ t.lastResult }}</td>
<td><div class="row-actions"><button v-if="t.statusClass === 'pending'" @click="ui.toast('执行任务')">立即执行</button><button v-else @click="ui.toast('任务详情已打开')">查看</button><button v-if="t.videoPolicyClass === 'record'" @click="ui.toast('任务完成后可下载原始视频')">下载原始视频</button></div></td>
</tr>
</tbody>
</table>
<div v-if="!filteredPlans.length" class="table-empty">暂无任务计划</div>
</div>
</div>
<!-- 执行记录 -->
<div class="business-panel" :class="{ active: view === 'records' }">
<div class="business-toolbar">
<div>
<select v-model="execFilter"><option value="">全部设备</option><option v-for="d in devices" :key="d.id" :value="d.name">{{ d.name }}</option></select>
<div class="business-search"><svg><use href="#i-search" /></svg><input v-model="execSearch" type="text" placeholder="搜索执行记录" /></div>
</div>
</div>
<div class="record-header"><div><h2>执行记录</h2><span>最近 30 天执行记录</span></div><select v-model="execResultFilter"><option value="">全部执行结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="cancelled">已取消</option></select></div>
<div class="business-table-wrap">
<table class="business-table execution-record-table">
<thead>
<tr>
<th>执行编号</th>
<th>任务名称</th>
<th>执行设备</th>
<th>开始时间</th>
<th>飞行时长</th>
<th>采集成果</th>
<th>执行结果</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filteredRecords" :key="r.id">
<td>{{ r.id }}</td>
<td><div class="primary-table-cell"><span class="task-table-icon"><svg><use href="#i-route" /></svg></span><span><strong>{{ r.name }}</strong><small>{{ r.type }}</small></span></div></td>
<td>{{ r.device }}</td>
<td>{{ r.start }}</td>
<td>{{ r.duration }}</td>
<td>{{ r.collect }}</td>
<td><span class="table-status" :class="r.statusClass"><i></i>{{ r.result }}</span></td>
<td><button class="table-text-action" :disabled="r.statusClass === 'mission'" @click="goReplay(r)">{{ r.statusClass === 'mission' ? '执行中' : '轨迹回放' }}</button></td>
</tr>
</tbody>
</table>
<div v-if="!filteredRecords.length" class="table-empty">暂无执行记录</div>
</div>
</div>
<!-- 航线库 -->
<div class="business-panel route-library" :class="{ active: view === 'routes' }">
<div class="route-library-view" :hidden="routeEditorOpen">
<div class="business-toolbar">
<label class="business-search"><svg><use href="#i-search" /></svg><input v-model="routeSearch" type="text" placeholder="搜索航线名称或区域" /></label>
<button class="plain-command" @click="openRouteEditor"><svg><use href="#i-plus" /></svg>新建航线</button>
</div>
<div class="route-grid">
<article v-for="r in filteredRoutes" :key="r.id">
<div class="route-map" :class="r.mapClass">
<svg><use href="#i-route" /></svg>
<span>{{ r.distance }}</span>
</div>
<div>
<strong>{{ r.name }}</strong>
<small>{{ r.waypoints }} 个航点 · 采集于 {{ r.collectedAt }}</small>
</div>
</article>
<div v-if="!filteredRoutes.length" class="table-empty" style="grid-column:1/-1">暂无航线</div>
</div>
</div>
<div class="route-editor" :hidden="!routeEditorOpen">
<header class="route-editor-header">
<button class="back-command" type="button" @click="closeRouteEditor"><svg><use href="#i-chevron" /></svg>返回航线库</button>
<div><strong>新建航线</strong><span>{{ routeSummary }}</span></div>
<div><button class="plain-command" type="button" :disabled="!routePoints.length" @click="undoRoutePoint"><svg><use href="#i-refresh" /></svg>撤销</button><button class="plain-command" type="button" :disabled="!routePoints.length" @click="clearRoutePoints"><svg><use href="#i-x" /></svg>清空</button><button class="primary-command" type="button" :disabled="routeSaving" @click="submitRoute">{{ routeSaving ? '保存中' : '保存航线' }}</button></div>
</header>
<div class="route-editor-body">
<aside class="route-config-panel">
<label><span>航线名称 <b>*</b></span><input v-model="routeForm.name" maxlength="128" placeholder="请输入航线名称" /></label>
<div class="route-default-title"><strong>新增航点默认参数</strong></div>
<div class="route-default-row">
<label><span>高度</span><div class="number-input"><input v-model.number="routeForm.altitude" type="number" min="20" max="120" /><em>m</em></div></label>
<label><span>速度</span><div class="number-input"><input v-model.number="routeForm.speed" type="number" min="1" max="20" step="0.1" /><em>m/s</em></div></label>
</div>
<label><span>转弯方式</span><div class="mode-segment route-turn-segment"><button type="button" :class="{ active: routeForm.turnMode === 'auto' }" @click="routeForm.turnMode = 'auto'">自动转弯</button><button type="button" :class="{ active: routeForm.turnMode === 'stop' }" @click="routeForm.turnMode = 'stop'">定点转弯</button></div></label>
<button class="apply-all-waypoints" type="button" :disabled="!routePoints.length" @click="applyRouteDefaults"><svg><use href="#i-settings" /></svg>应用到全部航点</button>
<div class="waypoint-heading"><strong>航点列表</strong><span>{{ routePoints.length }}</span></div>
<div class="waypoint-list">
<div v-if="!routePoints.length" class="waypoint-empty">点击右侧地图添加航点</div>
<div v-for="(point, index) in routePoints" :key="index" class="waypoint-item" @click="openWaypointEditor(index)"><b>{{ index + 1 }}</b><span><strong>{{ point.longitude.toFixed(6) }}, {{ point.latitude.toFixed(6) }}</strong><small>{{ point.altitude }} m · {{ point.speed }} m/s · {{ point.turnMode === 'auto' ? '自动转弯' : '定点转弯' }}</small></span><div class="waypoint-item-actions"><button type="button" title="删除航点" @click.stop="removeWaypoint(index)"><svg><use href="#i-x" /></svg></button></div></div>
</div>
<div class="route-form-error">{{ routeFormError }}</div>
</aside>
<div ref="routeMap" class="route-planner-map" @click="addWaypointFromMap" @mousemove="updateRouteCursor">
<div class="route-map-base"></div>
<svg class="route-drawing" viewBox="0 0 1000 600" preserveAspectRatio="none"><polyline :points="routePolyline" /><g><g v-for="(point, index) in routePoints" :key="index" @click.stop="openWaypointEditor(index)"><circle :cx="point.x" :cy="point.y" r="10" :fill="index === 0 ? '#35a66a' : index === routePoints.length - 1 ? '#c56c28' : '#2d82da'" /><text :x="point.x" :y="point.y">{{ index + 1 }}</text></g></g></svg>
<div class="route-map-status"><span><i></i>地图已加载</span><span>{{ routeCursor }}</span></div>
<div class="route-map-legend"><span><i class="start"></i>起点</span><span><i></i>航点</span><span><i class="end"></i>终点</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" :class="{ open: createTaskOpen }" @click.self="createTaskOpen = false">
<div class="modal add-dock-dialog task-dialog" role="dialog" aria-modal="true" aria-label="新建任务">
<header>
<div><span>任务管理</span><h3>新建任务</h3></div>
<button class="icon-button" type="button" @click="createTaskOpen = false"><svg><use href="#i-x" /></svg></button>
</header>
<form novalidate @submit.prevent="submitCreateTask">
<label><span>任务名称<b>*</b></span><input v-model="taskForm.name" type="text" maxlength="40" placeholder="例如:园区西侧道路巡检" /></label>
<div class="form-row">
<label><span>执行机巢<b>*</b></span>
<select v-model="taskForm.dockId">
<option value="">请选择机巢</option>
<option v-for="dock in deviceStore.docks" :key="dock.id" :value="dock.dockId">{{ dock.name }} · {{ dock.dockId }}</option>
</select>
</label>
<label><span>飞行航线<b>*</b></span>
<select v-model="taskForm.routeId">
<option value="">请选择航线</option>
<option v-for="taskRoute in routes" :key="taskRoute.id" :value="taskRoute.id">{{ taskRoute.name }}</option>
</select>
</label>
</div>
<div class="task-video-settings">
<div class="task-video-title"><strong>视频策略</strong><span>按任务配置</span></div>
<label class="task-video-option"><span><strong>原始视频录制</strong><small>任务结束后自动上传原始文件</small></span><input v-model="taskForm.rawRecordingEnabled" type="checkbox" /><i></i></label>
</div>
<div class="form-error">{{ taskFormError }}</div>
<footer>
<button class="plain-command" type="button" @click="createTaskOpen = false">取消</button>
<button class="primary-command" type="submit" :disabled="creatingTask">{{ creatingTask ? '创建中…' : '创建任务' }}</button>
</footer>
</form>
</div>
</div>
<div class="modal-backdrop" :class="{ open: waypointEditorOpen }" @click.self="closeWaypointEditor">
<div class="modal add-dock-dialog waypoint-dialog" role="dialog" aria-modal="true" aria-label="编辑航点">
<header><div><span>航线编辑</span><h3>编辑航点 {{ editingWaypointIndex + 1 }}</h3></div><button class="icon-button" type="button" @click="closeWaypointEditor"><svg><use href="#i-x" /></svg></button></header>
<form novalidate @submit.prevent="saveWaypoint">
<div class="waypoint-id-row"><span>航点 ID</span><strong>{{ editingWaypointIndex + 1 }}</strong></div>
<div class="form-row"><label><span>经度 <b>*</b></span><input v-model.number="waypointForm.longitude" type="number" step="any" /></label><label><span>纬度 <b>*</b></span><input v-model.number="waypointForm.latitude" type="number" step="any" /></label></div>
<div class="form-row"><label><span>高度 <b>*</b></span><div class="number-input"><input v-model.number="waypointForm.altitude" type="number" min="20" max="120" /><em>m</em></div></label><label><span>速度 <b>*</b></span><div class="number-input"><input v-model.number="waypointForm.speed" type="number" min="1" max="20" step="0.1" /><em>m/s</em></div></label></div>
<label><span>转弯方式</span><div class="mode-segment waypoint-turn-segment"><button type="button" :class="{ active: waypointForm.turnMode === 'auto' }" @click="waypointForm.turnMode = 'auto'">自动转弯</button><button type="button" :class="{ active: waypointForm.turnMode === 'stop' }" @click="waypointForm.turnMode = 'stop'">定点转弯</button></div></label>
<div class="route-form-error">{{ waypointFormError }}</div>
<footer><button class="plain-command" type="button" @click="closeWaypointEditor">取消</button><button class="primary-command" type="submit">保存航点</button></footer>
</form>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '../store/ui'
import request from '../api/request'
import { useDeviceStore } from '../store/devices'
const route = useRoute()
const router = useRouter()
const ui = useUiStore()
const deviceStore = useDeviceStore()
const view = ref(route.query.view || 'plans')
const search = ref('')
const planFilter = ref('')
const execSearch = ref('')
const execFilter = ref('')
const execResultFilter = ref('')
const routeSearch = ref('')
const routeEditorOpen = ref(false)
const routeSaving = ref(false)
const routeFormError = ref('')
const routeCursor = ref('119.820000, 30.310000')
const routeMap = ref(null)
const routeForm = reactive({ name: '', altitude: 50, speed: 10, turnMode: 'auto' })
const routePoints = ref([])
const waypointEditorOpen = ref(false)
const waypointFormError = ref('')
const editingWaypointIndex = ref(-1)
const waypointForm = reactive({ longitude: 0, latitude: 0, altitude: 50, speed: 10, turnMode: 'auto' })
const plans = ref([])
const records = ref([])
const routes = ref([])
const devices = computed(() => [...deviceStore.drones, ...deviceStore.docks])
const createTaskOpen = ref(false)
const creatingTask = ref(false)
const taskFormError = ref('')
const taskForm = reactive({
name: '',
dockId: '',
routeId: '',
scheduleType: 'once',
scheduleCron: '',
rawRecordingEnabled: false
})
watch(
() => route.query.view,
(v) => { if (v) view.value = v }
)
function setView(v) {
router.push({ name: 'tasks', query: { view: v } })
}
const TASK_STATUS = {
enabled: { status: '待执行', statusClass: 'pending' },
running: { status: '执行中', statusClass: 'mission' },
paused: { status: '已暂停', statusClass: 'offline' },
draft: { status: '已暂停', statusClass: 'offline' },
disabled: { status: '已暂停', statusClass: 'offline' }
}
const EXEC_STATUS = {
pending: '待执行',
running: '执行中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消'
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
await deviceStore.load()
const [taskPage, execPage, routePage] = await Promise.all([
request.get('/v1/tasks', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/executions', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/routes', { params: { pageNum: 1, pageSize: 100 } })
])
plans.value = (taskPage?.records || []).map((t) => {
const st = TASK_STATUS[t.status] || TASK_STATUS.draft
return {
id: t.id,
code: `JG-TSK-${String(t.id).slice(-8)}`,
name: t.name,
route: t.routeId ? `航线 #${t.routeId}` : '未绑定航线',
device: t.dockId || '--',
cycle: t.scheduleType === 'cron' ? (t.scheduleCron || '定时执行') : '单次执行',
next: t.scheduleType === 'cron' ? (t.scheduleCron || '--') : '待手动执行',
videoPolicy: t.videoPolicy === 'raw' ? '原始录制' : '未启用',
videoPolicyClass: t.videoPolicy === 'raw' ? 'record' : 'none',
status: st.status,
statusClass: st.statusClass,
lastResult: '尚未执行'
}
})
records.value = (execPage?.records || []).map((r) => {
const dur = r.startTime && r.endTime
? Math.max(0, Math.round((new Date(r.endTime) - new Date(r.startTime)) / 60000))
: null
return {
id: String(r.id),
name: r.taskId || '手动任务',
type: r.taskId ? '定时任务' : '手动任务',
device: r.droneSn || r.dockId || '--',
start: fmtTime(r.startTime),
duration: dur != null ? `${dur} min` : '--',
collect: r.status === 'running' ? '原始视频录制中' : '--',
result: EXEC_STATUS[r.status] || r.status || '--',
status: r.status || '',
statusClass: r.status === 'running' ? 'mission' : r.status === 'succeeded' ? 'online' : r.status === 'failed' ? 'danger' : 'pending'
}
})
routes.value = (routePage?.records || []).map((r, i) => ({
id: String(r.id),
name: r.name,
waypoints: Array.isArray(r.waypoints) ? r.waypoints.length : 0,
distance: Array.isArray(r.waypoints) ? `${routeDistance(r.waypoints.map((p) => ({ longitude: Number(p.longitude), latitude: Number(p.latitude) })) ).toFixed(1)} km` : '--',
collectedAt: fmtTime(r.createdAt),
mapClass: i % 2 === 0 ? 'route-a' : 'route-c'
}))
} catch (e) {
ui.toast(e.message || '加载任务数据失败')
}
}
onMounted(load)
const filteredPlans = computed(() => {
const q = search.value.trim().toLowerCase()
return plans.value.filter((p) => {
if (planFilter.value && p.statusClass !== planFilter.value) return false
if (!q) return true
return p.name.toLowerCase().includes(q) || p.device.toLowerCase().includes(q)
})
})
const filteredRecords = computed(() => {
const q = execSearch.value.trim().toLowerCase()
return records.value.filter((r) => {
if (execFilter.value && r.device !== execFilter.value) return false
if (execResultFilter.value && r.status !== execResultFilter.value) return false
if (!q) return true
return r.name.toLowerCase().includes(q) || r.device.toLowerCase().includes(q)
})
})
const filteredRoutes = computed(() => {
const q = routeSearch.value.trim().toLowerCase()
if (!q) return routes.value
return routes.value.filter((r) => r.name.toLowerCase().includes(q))
})
const routePolyline = computed(() => routePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
const routeSummary = computed(() => `${routePoints.value.length} 个航点 · ${routeDistance(routePoints.value).toFixed(1)} km`)
function resetRouteEditor() {
Object.assign(routeForm, { name: '', altitude: 50, speed: 10, turnMode: 'auto' })
routePoints.value = []
routeFormError.value = ''
routeCursor.value = '119.820000, 30.310000'
}
function openRouteEditor() {
resetRouteEditor()
routeEditorOpen.value = true
}
function closeRouteEditor() {
routeEditorOpen.value = false
waypointEditorOpen.value = false
}
function routeDistance(points) {
return points.slice(1).reduce((total, point, index) => {
const previous = points[index]
const lat1 = previous.latitude * Math.PI / 180
const lat2 = point.latitude * Math.PI / 180
const dLat = lat2 - lat1
const dLon = (point.longitude - previous.longitude) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2
return total + 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}, 0)
}
function mapPoint(event) {
const rect = routeMap.value.getBoundingClientRect()
const x = Math.max(0, Math.min(1000, (event.clientX - rect.left) / rect.width * 1000))
const y = Math.max(0, Math.min(600, (event.clientY - rect.top) / rect.height * 600))
return { x, y, longitude: 119.8 + x / 1000 * 0.04, latitude: 30.33 - y / 600 * 0.04 }
}
function updateRouteCursor(event) {
const point = mapPoint(event)
routeCursor.value = `${point.longitude.toFixed(6)}, ${point.latitude.toFixed(6)}`
}
function validRouteDefaults() {
if (routeForm.altitude < 20 || routeForm.altitude > 120) {
routeFormError.value = '默认高度应在 20~120 米之间。'
return false
}
if (routeForm.speed < 1 || routeForm.speed > 20) {
routeFormError.value = '默认速度应在 1~20 m/s 之间。'
return false
}
return true
}
function addWaypointFromMap(event) {
if (!validRouteDefaults()) return
const point = mapPoint(event)
routePoints.value.push({ ...point, altitude: Number(routeForm.altitude), speed: Number(routeForm.speed), turnMode: routeForm.turnMode })
routeFormError.value = ''
}
function undoRoutePoint() {
routePoints.value.pop()
}
function clearRoutePoints() {
routePoints.value = []
}
function applyRouteDefaults() {
if (!validRouteDefaults()) return
routePoints.value = routePoints.value.map((point) => ({ ...point, altitude: Number(routeForm.altitude), speed: Number(routeForm.speed), turnMode: routeForm.turnMode }))
routeFormError.value = ''
}
function openWaypointEditor(index) {
const point = routePoints.value[index]
if (!point) return
editingWaypointIndex.value = index
Object.assign(waypointForm, point)
waypointFormError.value = ''
waypointEditorOpen.value = true
}
function closeWaypointEditor() {
waypointEditorOpen.value = false
editingWaypointIndex.value = -1
}
function saveWaypoint() {
if (!Number.isFinite(waypointForm.longitude) || !Number.isFinite(waypointForm.latitude)) {
waypointFormError.value = '请输入有效的经纬度。'
return
}
if (waypointForm.altitude < 20 || waypointForm.altitude > 120) {
waypointFormError.value = '高度应在 20~120 米之间。'
return
}
if (waypointForm.speed < 1 || waypointForm.speed > 20) {
waypointFormError.value = '速度应在 1~20 m/s 之间。'
return
}
const x = (waypointForm.longitude - 119.8) / 0.04 * 1000
const y = (30.33 - waypointForm.latitude) / 0.04 * 600
if (x < 0 || x > 1000 || y < 0 || y > 600) {
waypointFormError.value = '经纬度超出当前地图显示范围。'
return
}
routePoints.value[editingWaypointIndex.value] = { ...waypointForm, x, y }
closeWaypointEditor()
}
function removeWaypoint(index) {
routePoints.value.splice(index, 1)
}
async function submitRoute() {
const name = routeForm.name.trim()
if (!name) {
routeFormError.value = '请输入航线名称。'
return
}
if (routePoints.value.length < 2) {
routeFormError.value = '至少需要设置 2 个航点。'
return
}
routeSaving.value = true
try {
await request.post('/v1/routes', {
name,
waypoints: routePoints.value.map((point) => ({ longitude: point.longitude, latitude: point.latitude, altitude: point.altitude, speed: point.speed, yaw: 0, holdSec: point.turnMode === 'stop' ? 1 : 0 }))
})
closeRouteEditor()
ui.toast('航线已保存')
await load()
} catch (e) {
routeFormError.value = e.message || '保存航线失败'
} finally {
routeSaving.value = false
}
}
function resetPlanFilter() {
search.value = ''
planFilter.value = ''
}
const todayTaskCount = computed(() => plans.value.length)
const runningTaskCount = computed(() => plans.value.filter((p) => p.statusClass === 'mission').length)
const pendingTaskCount = computed(() => plans.value.filter((p) => p.statusClass === 'pending').length)
const completedTodayCount = computed(() => records.value.filter((r) => r.status === 'succeeded').length)
function openCreateTask() {
Object.assign(taskForm, {
name: '',
dockId: deviceStore.docks[0]?.dockId || '',
routeId: '',
scheduleType: 'once',
scheduleCron: '',
rawRecordingEnabled: false
})
taskFormError.value = ''
createTaskOpen.value = true
}
async function submitCreateTask() {
taskFormError.value = ''
const name = taskForm.name.trim()
if (!name || !taskForm.dockId || !taskForm.routeId) {
taskFormError.value = '请完整填写任务名称、执行机巢和飞行航线。'
return
}
if (taskForm.scheduleType === 'cron' && !taskForm.scheduleCron.trim()) {
taskFormError.value = '请填写定时执行表达式。'
return
}
creatingTask.value = true
try {
await request.post('/v1/tasks', {
name,
dockId: taskForm.dockId,
routeId: Number(taskForm.routeId) || 0,
videoPolicy: taskForm.rawRecordingEnabled ? 'raw' : 'replay'
})
createTaskOpen.value = false
ui.toast('任务已创建')
await load()
} catch (e) {
taskFormError.value = e.message || '创建任务失败'
} finally {
creatingTask.value = false
}
}
function goReplay(r) {
router.push({ name: 'replay', params: { key: r.id } })
}
</script>

126
src/views/Users.vue

@ -0,0 +1,126 @@
<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>用户列表</h1>
<span> {{ total }} 个注册用户</span>
</div>
<button class="icon-command" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></button>
</header>
<div class="business-workspace">
<div class="asset-toolbar">
<div class="asset-search"><svg><use href="#i-search" /></svg><input v-model="keyword" type="text" placeholder="搜索姓名 / 手机号 / 邮箱" @keyup.enter="reload" /></div>
<div class="toolbar-group">
<select v-model="roleFilter" @change="reload">
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr>
<th style="width:24%">用户</th>
<th style="width:16%">手机号</th>
<th style="width:20%">邮箱</th>
<th style="width:12%">角色</th>
<th style="width:10%">状态</th>
<th style="width:18%">注册时间</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>
<div class="asset-name-cell">
<span class="table-asset-icon" :class="u.role === 'admin' ? 'pending' : 'drone'">{{ u.initial }}</span>
<span><strong>{{ u.name }}</strong><small>ID {{ u.id }}</small></span>
</div>
</td>
<td>{{ u.phone }}</td>
<td>{{ u.email || '—' }}</td>
<td><span class="table-status" :class="u.role === 'admin' ? 'mission' : 'online'"><i></i>{{ u.roleName }}</span></td>
<td><span class="table-status" :class="u.statusClass"><i></i>{{ u.statusName }}</span></td>
<td>{{ u.createdAt }}</td>
</tr>
</tbody>
</table>
<div v-if="!users.length" class="table-empty">暂无用户</div>
</div>
<div class="table-footer">
<span> {{ total }} 个用户 · {{ pageNum }} / {{ pages }} </span>
<div>
<button @click="goPage(pageNum - 1)"><svg><use href="#i-chevron" /></svg></button>
<b>{{ pageNum }}</b>
<button @click="goPage(pageNum + 1)"><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useUiStore } from '../store/ui'
import request from '../api/request'
const ui = useUiStore()
const users = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(20)
const pages = ref(1)
const keyword = ref('')
const roleFilter = ref('')
const ROLE_NAME = { admin: '管理员', user: '普通用户' }
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (roleFilter.value) params.role = roleFilter.value
const page = await request.get('/v1/users', { params })
total.value = page?.total || 0
pages.value = page?.pages || 1
users.value = (page?.records || []).map((u) => ({
id: String(u.id),
name: u.name,
phone: u.phone,
email: u.email || '',
role: u.role,
roleName: ROLE_NAME[u.role] || u.role,
initial: (u.name || '?').charAt(0),
statusClass: u.status === 0 ? 'offline' : 'online',
statusName: u.status === 0 ? '已禁用' : '正常',
createdAt: fmtTime(u.createdAt)
}))
} catch (e) {
ui.toast(e.message || '加载用户列表失败')
}
}
function reload() {
pageNum.value = 1
load()
}
function goPage(p) {
if (p < 1 || p > pages.value) return
pageNum.value = p
load()
}
onMounted(load)
</script>

17
vite.config.js

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, '')
}
}
}
})
Loading…
Cancel
Save