commit
01bdf0eb17
33 changed files with 7384 additions and 0 deletions
@ -0,0 +1,4 @@ |
|||||
|
node_modules |
||||
|
dist |
||||
|
*.log |
||||
|
.DS_Store |
||||
@ -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> |
||||
File diff suppressed because it is too large
@ -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" |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@ -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> |
||||
@ -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 |
||||
@ -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> |
||||
@ -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> |
||||
@ -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') |
||||
@ -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 |
||||
@ -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 } |
||||
|
}) |
||||
@ -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 } |
||||
|
}) |
||||
@ -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 } |
||||
|
}) |
||||
File diff suppressed because one or more lines are too long
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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…
Reference in new issue