You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

998 lines
40 KiB

<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">
<div class="business-search">
<t-input v-model="search" clearable placeholder="搜索任务名称、编号或设备">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
<div class="toolbar-group">
<t-select v-model="planFilter" :options="planFilterOptions" placeholder="全部状态" style="width: 140px" />
<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="canExecuteTask(t)" @click="executeTask(t)">立即执行</button><button @click="deleteTask(t)">删除</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 class="toolbar-group">
<t-select v-model="execFilter" :options="execFilterOptions" placeholder="全部设备" style="width: 160px" />
<div class="business-search">
<t-input v-model="execSearch" clearable placeholder="搜索执行记录">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
</div>
</div>
<div class="record-header"><div><h2>执行记录</h2><span>最近 30 天执行记录</span></div><t-select v-model="execResultFilter" :options="execResultFilterOptions" placeholder="全部执行结果" style="width: 160px" /></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><div class="row-actions"><button class="table-text-action" :disabled="r.statusClass === 'mission'" @click="goReplay(r)">{{ r.statusClass === 'mission' ? '执行中' : '轨迹回放' }}</button><button v-if="r.rawVideoId" class="table-text-action" :disabled="r.rawVideoStatus !== 'ready'" @click="downloadRawVideo(r)">{{ r.rawVideoStatus === 'ready' ? '下载原视频' : '视频处理中' }}</button></div></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">
<div class="business-search">
<t-input v-model="routeSearch" clearable placeholder="搜索航线名称或区域">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
<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><t-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"><t-input-number v-model="routeForm.altitude" theme="normal" :min="20" :max="120" style="width: 100%" /><em>m</em></div></label>
<label><span>速度</span><div class="number-input"><t-input-number v-model="routeForm.speed" theme="normal" :min="1" :max="20" :step="0.1" style="width: 100%" /><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="routeMapEl" class="route-planner-map">
<div class="route-map-status"><span><i></i>{{ routeMapReady ? '地图已加载' : '地图加载中…' }}</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><t-input v-model="taskForm.name" :maxlength="40" placeholder="例如:园区西侧道路巡检" /></label>
<div class="form-row">
<label><span>执行机巢<b>*</b></span>
<t-select v-model="taskForm.dockId" :options="dockOptions" placeholder="请选择机巢" style="width: 100%" />
</label>
<label><span>飞行航线<b>*</b></span>
<t-select v-model="taskForm.routeId" :options="routeOptions" placeholder="请选择航线" style="width: 100%" />
</label>
</div>
<div class="task-schedule-settings">
<div class="task-video-title"><strong>执行方式</strong><span>按计划自动执行</span></div>
<div class="mode-segment">
<button type="button" :class="{ active: taskForm.scheduleType === 'once' }" @click="setScheduleType('once')">手动执行</button>
<button type="button" :class="{ active: taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'day' }" @click="setScheduleType('day')">每天</button>
<button type="button" :class="{ active: taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'week' }" @click="setScheduleType('week')">每周</button>
<button type="button" :class="{ active: taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom' }" @click="setScheduleType('custom')">自定义 Cron</button>
</div>
<div v-if="taskForm.scheduleType === 'cron' && taskForm.schedulePeriod !== 'custom'" class="form-row">
<label v-if="taskForm.schedulePeriod === 'week'"><span>星期</span><t-select v-model="taskForm.scheduleWeekday" :options="weekdayOptions" style="width: 100%" /></label>
<label><span>执行时间</span><t-time-picker v-model="taskForm.scheduleTime" format="HH:mm" allow-input style="width: 100%" /></label>
</div>
<label v-if="taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom'"><span>Cron 表达式<b>*</b></span><t-input v-model.trim="taskForm.scheduleCron" :maxlength="64" placeholder="例如:0 8 * * 1-5" /><small>标准五段式:分 时 日 月 周,不支持秒级。</small></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><t-switch v-model="taskForm.rawRecordingEnabled" /></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><t-input-number v-model="waypointForm.longitude" theme="normal" :min="-180" :max="180" :step="0.000001" style="width: 100%" /></label><label><span>纬度 <b>*</b></span><t-input-number v-model="waypointForm.latitude" theme="normal" :min="-90" :max="90" :step="0.000001" style="width: 100%" /></label></div>
<div class="form-row"><label><span>高度 <b>*</b></span><div class="number-input"><t-input-number v-model="waypointForm.altitude" theme="normal" :min="20" :max="120" style="width: 100%" /><em>m</em></div></label><label><span>速度 <b>*</b></span><div class="number-input"><t-input-number v-model="waypointForm.speed" theme="normal" :min="1" :max="20" :step="0.1" style="width: 100%" /><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, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import * as urls from '@/config/urls'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
const ROUTE_SOURCE = 'task-route'
const ROUTE_LINE = 'task-route-line'
const WP_SOURCE = 'task-waypoints'
const WP_CIRCLE = 'task-waypoints-circle'
const WP_LABEL = 'task-waypoints-label'
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 planFilterOptions = [
{ label: '全部状态', value: '' },
{ label: '执行中', value: 'mission' },
{ label: '待执行', value: 'pending' },
{ label: '已暂停', value: 'offline' }
]
const execSearch = ref('')
const execFilter = ref('')
const execResultFilter = ref('')
const execResultFilterOptions = [
{ label: '全部执行结果', value: '' },
{ label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' },
{ label: '已取消', value: 'cancelled' }
]
const routeSearch = ref('')
const routeEditorOpen = ref(false)
const routeSaving = ref(false)
const routeFormError = ref('')
const routeCursor = ref('--')
const routeMapEl = ref(null)
const routeMapReady = ref(false)
let routeMapInstance = null
let routeMapAttached = false
let pendingStyleHandler = 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([])
let refreshTimer = null
const routes = ref([])
const devices = computed(() => [...deviceStore.drones, ...deviceStore.docks])
const execFilterOptions = computed(() => [
{ label: '全部设备', value: '' },
...devices.value.map((d) => ({ label: d.name, value: d.dockId || d.droneSn }))
])
const dockOptions = computed(() =>
deviceStore.docks.map((dock) => ({
label: `${dock.name} · ${dock.dockId}`,
value: dock.dockId
}))
)
const routeOptions = computed(() =>
routes.value.map((taskRoute) => ({
label: taskRoute.name,
value: taskRoute.id
}))
)
const createTaskOpen = ref(false)
const creatingTask = ref(false)
const taskFormError = ref('')
const taskForm = reactive({
name: '',
dockId: '',
routeId: '',
scheduleType: 'once',
schedulePeriod: 'day',
scheduleWeekday: 1,
scheduleTime: '08:00',
scheduleCron: '',
rawRecordingEnabled: false
})
const weekdayOptions = [
{ value: 1, label: '星期一' },
{ value: 2, label: '星期二' },
{ value: 3, label: '星期三' },
{ value: 4, label: '星期四' },
{ value: 5, label: '星期五' },
{ value: 6, label: '星期六' },
{ value: 0, label: '星期日' }
]
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: 'pending' },
disabled: { status: '已暂停', statusClass: 'offline' }
}
const EXEC_STATUS = {
pending: '等待设备开始',
running: '执行中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消'
}
function formatSchedule(task) {
if (task.scheduleType !== 'cron' || !task.scheduleCron) return '手动执行'
const parts = task.scheduleCron.trim().split(/\s+/)
if (parts.length !== 5) return `自定义 Cron:${task.scheduleCron}`
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return `每日 ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
}
if (dayOfMonth === '*' && month === '*' && /^\d$/.test(dayOfWeek)) {
return `每周${dayOfWeek === '0' ? '日' : dayOfWeek} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
}
return `自定义 Cron:${task.scheduleCron}`
}
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 toExecutionRecord(r, taskName, type, rawVideo) {
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: taskName || r.taskId || '手动任务',
type: type || (r.taskId ? '任务计划' : '手动任务'),
device: r.droneSn || r.dockId || '--',
start: fmtTime(r.startTime),
duration: dur != null ? `${dur} min` : '--',
collect: rawVideo ? (rawVideo.status === 'ready' ? '原始视频已上传' : '原始视频处理中') : 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',
rawVideoId: rawVideo?.id || '',
rawVideoStatus: rawVideo?.status || ''
}
}
async function load() {
try {
await deviceStore.load()
const [taskPage, execPage, routePage, videoPage] = await Promise.all([
request.get(urls.TASKS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.EXECUTIONS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.ROUTES, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } })
])
plans.value = (taskPage?.records || []).map((t) => {
return {
id: t.id,
code: `JG-TSK-${String(t.id).slice(-8)}`,
name: t.name,
route: t.routeId ? `航线 #${t.routeId}` : '未绑定航线',
device: t.dockId || '--',
cycle: formatSchedule(t),
next: t.scheduleType === 'cron' ? formatSchedule(t) : '待手动执行',
videoPolicy: t.videoPolicy === 'raw' ? '原始录制' : '未启用',
videoPolicyClass: t.videoPolicy === 'raw' ? 'record' : 'none',
rawStatus: t.planStatus || t.status || 'enabled',
status: (TASK_STATUS[t.planStatus] || TASK_STATUS[t.status] || TASK_STATUS.draft).status,
statusClass: (TASK_STATUS[t.planStatus] || TASK_STATUS[t.status] || TASK_STATUS.draft).statusClass,
lastResult: t.latestExecution ? (EXEC_STATUS[t.latestExecution.status] || t.latestExecution.status || '未知') : '尚未执行'
}
})
const taskNames = new Map((taskPage?.records || []).map((task) => [task.id, task.name]))
const videosByExecution = new Map((videoPage?.records || []).map((video) => [String(video.executionId), video]))
records.value = (execPage?.records || []).map((r) => {
const video = videosByExecution.get(String(r.id))
return toExecutionRecord(r, taskNames.get(r.taskId), '', video ? { id: String(video.id), status: video.status } : null)
})
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'
}))
const hasActiveExecution = (execPage?.records || []).some((r) => ['pending', 'running'].includes(r.status))
if (hasActiveExecution && !refreshTimer) refreshTimer = window.setInterval(load, 5000)
if (!hasActiveExecution && refreshTimer) {
window.clearInterval(refreshTimer)
refreshTimer = null
}
} catch (e) {
ui.toast(e.message || '加载任务数据失败')
}
}
onMounted(load)
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer)
teardownRouteMap()
})
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 routeSummary = computed(() => `${routePoints.value.length} 个航点 · ${routeDistance(routePoints.value).toFixed(1)} km`)
function formatCoord(lon, lat) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '--'
return `${lon.toFixed(6)}, ${lat.toFixed(6)}`
}
function resetRouteEditor() {
Object.assign(routeForm, { name: '', altitude: 50, speed: 10, turnMode: 'auto' })
routePoints.value = []
routeFormError.value = ''
routeCursor.value = '--'
}
function clearTaskLayers(map) {
if (!map) return
for (const id of [WP_LABEL, WP_CIRCLE, ROUTE_LINE]) {
if (map.getLayer(id)) map.removeLayer(id)
}
for (const id of [WP_SOURCE, ROUTE_SOURCE]) {
if (map.getSource(id)) map.removeSource(id)
}
}
function ensureTaskLayers(map) {
if (!map || !map.isStyleLoaded()) return false
if (!map.getSource(ROUTE_SOURCE)) {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
}
if (!map.getSource(WP_SOURCE)) {
map.addSource(WP_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
}
if (!map.getLayer(ROUTE_LINE)) {
map.addLayer({
id: ROUTE_LINE,
type: 'line',
source: ROUTE_SOURCE,
paint: {
'line-color': '#2d82da',
'line-width': 3,
'line-opacity': 0.95,
},
})
}
if (!map.getLayer(WP_CIRCLE)) {
map.addLayer({
id: WP_CIRCLE,
type: 'circle',
source: WP_SOURCE,
paint: {
'circle-radius': 9,
'circle-color': ['get', 'color'],
'circle-stroke-color': '#fff',
'circle-stroke-width': 2,
},
})
}
if (!map.getLayer(WP_LABEL)) {
map.addLayer({
id: WP_LABEL,
type: 'symbol',
source: WP_SOURCE,
layout: {
'text-field': ['get', 'indexLabel'],
'text-size': 11,
'text-allow-overlap': true,
},
paint: { 'text-color': '#fff' },
})
}
return true
}
function syncRouteLayer() {
if (!routeMapInstance || !routeMapInstance.isStyleLoaded()) return
if (!ensureTaskLayers(routeMapInstance)) return
const coords = routePoints.value
.map((p) => [Number(p.longitude), Number(p.latitude)])
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
const lineData = {
type: 'FeatureCollection',
features: coords.length >= 2
? [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }]
: [],
}
const wpData = {
type: 'FeatureCollection',
features: coords.map((c, i) => {
const isStart = i === 0
const isEnd = i === coords.length - 1 && coords.length > 1
return {
type: 'Feature',
properties: {
index: i,
indexLabel: String(i + 1),
color: isStart ? '#35a66a' : isEnd ? '#c56c28' : '#2d82da',
},
geometry: { type: 'Point', coordinates: c },
}
}),
}
routeMapInstance.getSource(ROUTE_SOURCE)?.setData(lineData)
routeMapInstance.getSource(WP_SOURCE)?.setData(wpData)
}
function onRouteMapClick(e) {
if (!routeMapAttached || !routeMapInstance) return
const hit = routeMapInstance.queryRenderedFeatures(e.point, {
layers: [WP_CIRCLE, WP_LABEL].filter((id) => routeMapInstance.getLayer(id)),
})
if (hit[0]?.properties?.index != null) {
openWaypointEditor(Number(hit[0].properties.index))
return
}
if (!validRouteDefaults()) return
const { lng, lat } = e.lngLat
routePoints.value.push({
longitude: lng,
latitude: lat,
altitude: Number(routeForm.altitude),
speed: Number(routeForm.speed),
turnMode: routeForm.turnMode,
})
routeCursor.value = formatCoord(lng, lat)
routeFormError.value = ''
syncRouteLayer()
}
function onRouteMapMove(e) {
if (!routeMapAttached) return
routeCursor.value = formatCoord(e.lngLat.lng, e.lngLat.lat)
}
async function attachRouteMap() {
await nextTick()
const host = routeMapEl.value
if (!host) return
const map = await commonRefs.getRef('map')
if (!map) {
routeMapReady.value = false
ui.toast('地图未初始化(缺少 Mapbox token)')
return
}
routeMapInstance = map
mapHelper.replaceMapContainer(host)
mapHelper.toggleMapMode({ is2D: true })
routeMapAttached = true
routeMapReady.value = false
const boot = () => {
pendingStyleHandler = null
if (!routeMapAttached || routeMapInstance !== map) return
clearTaskLayers(map)
if (!ensureTaskLayers(map)) return
map.on('click', onRouteMapClick)
map.on('mousemove', onRouteMapMove)
const c = map.getCenter()
routeCursor.value = formatCoord(c.lng, c.lat)
routeMapReady.value = true
requestAnimationFrame(() => {
map.resize()
syncRouteLayer()
})
}
if (map.isStyleLoaded()) {
boot()
} else {
pendingStyleHandler = boot
map.once('load', pendingStyleHandler)
}
}
function teardownRouteMap() {
if (routeMapInstance && pendingStyleHandler) {
routeMapInstance.off('load', pendingStyleHandler)
pendingStyleHandler = null
}
if (routeMapInstance) {
routeMapInstance.off('click', onRouteMapClick)
routeMapInstance.off('mousemove', onRouteMapMove)
clearTaskLayers(routeMapInstance)
}
if (routeMapAttached) {
mapHelper.restoreMapContainer()
routeMapAttached = false
}
routeMapInstance = null
routeMapReady.value = false
}
function openRouteEditor() {
resetRouteEditor()
routeEditorOpen.value = true
attachRouteMap()
}
function closeRouteEditor() {
teardownRouteMap()
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 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 undoRoutePoint() {
routePoints.value.pop()
syncRouteLayer()
}
function clearRoutePoints() {
routePoints.value = []
syncRouteLayer()
}
function applyRouteDefaults() {
if (!validRouteDefaults()) return
routePoints.value = routePoints.value.map((point) => ({
longitude: point.longitude,
latitude: point.latitude,
altitude: Number(routeForm.altitude),
speed: Number(routeForm.speed),
turnMode: routeForm.turnMode,
}))
routeFormError.value = ''
syncRouteLayer()
}
function openWaypointEditor(index) {
const point = routePoints.value[index]
if (!point) return
editingWaypointIndex.value = index
Object.assign(waypointForm, {
longitude: point.longitude,
latitude: point.latitude,
altitude: point.altitude,
speed: point.speed,
turnMode: point.turnMode,
})
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.longitude < -180 || waypointForm.longitude > 180 || waypointForm.latitude < -90 || waypointForm.latitude > 90) {
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
}
routePoints.value[editingWaypointIndex.value] = {
longitude: Number(waypointForm.longitude),
latitude: Number(waypointForm.latitude),
altitude: Number(waypointForm.altitude),
speed: Number(waypointForm.speed),
turnMode: waypointForm.turnMode,
}
syncRouteLayer()
closeWaypointEditor()
}
function removeWaypoint(index) {
routePoints.value.splice(index, 1)
syncRouteLayer()
}
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(urls.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 = ''
}
async function deleteTask(task) {
if (!window.confirm(`确定永久删除任务“${task.name}”吗?此操作不可恢复。`)) return
try {
await request.delete(urls.TASK(task.id))
ui.toast('任务已删除')
await load()
} catch (e) {
ui.toast(e.message || '删除任务失败')
}
}
function canExecuteTask(task) {
return task.rawStatus !== 'disabled' && task.rawStatus !== 'paused' && task.statusClass !== 'mission'
}
async function executeTask(task) {
try {
const result = await request.post(urls.TASK_EXECUTE(task.id))
const execution = result?.execution
task.lastResult = EXEC_STATUS[execution?.status] || '等待设备开始'
if (execution) {
records.value.unshift(toExecutionRecord(execution, task.name, task.cycle))
}
ui.toast('任务已下发')
await load()
} catch (e) {
ui.toast(e.message || '任务执行失败')
}
}
function setScheduleType(type) {
if (type === 'once') {
taskForm.scheduleType = 'once'
taskForm.scheduleCron = ''
return
}
taskForm.scheduleType = 'cron'
taskForm.schedulePeriod = type
if (type !== 'custom') taskForm.scheduleCron = ''
}
function buildScheduleCron() {
if (taskForm.scheduleType === 'once') return ''
if (taskForm.schedulePeriod === 'custom') return taskForm.scheduleCron.trim()
const [hour, minute] = taskForm.scheduleTime.split(':').map(Number)
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return ''
const dayOfWeek = taskForm.schedulePeriod === 'week' ? taskForm.scheduleWeekday : '*'
return `${minute} ${hour} * * ${dayOfWeek}`
}
function isBasicCronExpression(value) {
const parts = value.trim().split(/\s+/)
return parts.length === 5 && parts.every((part) => /^[\d*/?,\-]+$/.test(part))
}
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',
schedulePeriod: 'day',
scheduleWeekday: 1,
scheduleTime: '08:00',
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
}
const scheduleCron = buildScheduleCron()
if (taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom' && !isBasicCronExpression(scheduleCron)) {
taskFormError.value = '请输入合法的五段式 Cron 表达式,例如:0 8 * * 1-5。'
return
}
if (taskForm.scheduleType === 'cron' && !scheduleCron) {
taskFormError.value = '请选择有效的执行时间。'
return
}
creatingTask.value = true
try {
await request.post(urls.TASKS, {
name,
dockId: taskForm.dockId,
routeId: Number(taskForm.routeId) || 0,
scheduleType: taskForm.scheduleType,
scheduleCron,
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 } })
}
async function downloadRawVideo(record) {
if (!record.rawVideoId || record.rawVideoStatus !== 'ready') return
try {
const res = await request.post(urls.VIDEO_DOWNLOAD(record.rawVideoId))
if (res?.downloadUrl) {
window.open(res.downloadUrl, '_blank')
ui.toast('开始下载原视频')
} else {
ui.toast('暂无下载地址')
}
} catch (e) {
ui.toast(e.message || '下载失败')
}
}
</script>
<style scoped>
.business-search :deep(.t-input) {
width: 100%;
}
.business-search {
border: 0;
padding: 0;
height: auto;
background: transparent;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-group :deep(.t-select) {
flex-shrink: 0;
}
.add-dock-dialog :deep(.t-input),
.add-dock-dialog :deep(.t-input-number),
.add-dock-dialog :deep(.t-select),
.add-dock-dialog :deep(.t-time-picker),
.route-config-panel :deep(.t-input),
.route-config-panel :deep(.t-input-number) {
width: 100%;
}
.number-input :deep(.t-input-number) {
width: 100%;
}
.number-input :deep(.t-input) {
padding-right: 34px;
}
.task-video-option :deep(.t-switch) {
justify-self: end;
}
</style>