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.
 
 
 
 

1077 lines
40 KiB

<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>任务管理</h1>
<span>今日 {{ todayTaskCount }} 个计划 · {{ runningTaskCount }} 个执行中</span>
</div>
<t-button theme="primary" @click="openCreateTask"><svg><use href="#i-plus" /></svg>新建任务</t-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">
<t-tabs v-model="view" class="business-tdesign-tabs" @change="setView">
<t-tab-panel value="plans" :label="plansTabLabel" :destroy-on-hide="false" />
<t-tab-panel value="records" :label="recordsTabLabel" :destroy-on-hide="false" />
<t-tab-panel value="routes" :label="routesTabLabel" :destroy-on-hide="false" />
</t-tabs>
<!-- 任务计划 -->
<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" />
<t-button variant="outline" @click="resetPlanFilter">重置</t-button>
</div>
</div>
<div class="business-table-wrap">
<t-table
row-key="id"
:data="filteredPlans"
:columns="planColumns"
empty="暂无任务计划"
>
<template #name="{ row }">
<div class="primary-table-cell">
<span class="task-table-icon" :class="{ muted: row.statusClass === 'offline' }"><svg><use href="#i-route" /></svg></span>
<span><strong>{{ row.name }}</strong><small>{{ row.code }} · {{ row.route }}</small></span>
</div>
</template>
<template #next="{ row }">
<span :class="{ 'blue-text': row.statusClass === 'mission' }">{{ row.next }}</span>
</template>
<template #videoPolicy="{ row }">
<span class="media-policy" :class="row.videoPolicyClass">{{ row.videoPolicy }}</span>
</template>
<template #status="{ row }">
<span class="table-status" :class="row.statusClass"><i></i>{{ row.status }}</span>
</template>
<template #actions="{ row }">
<div class="row-actions">
<t-button v-if="canExecuteTask(row)" variant="text" theme="primary" @click="executeTask(row)">立即执行</t-button>
<t-button variant="text" theme="danger" @click="deleteTask(row)">删除</t-button>
</div>
</template>
</t-table>
</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">
<t-table
row-key="id"
:data="filteredRecords"
:columns="recordColumns"
empty="暂无执行记录"
>
<template #name="{ row }">
<div class="primary-table-cell">
<span class="task-table-icon"><svg><use href="#i-route" /></svg></span>
<span><strong>{{ row.name }}</strong><small>{{ row.type }}</small></span>
</div>
</template>
<template #result="{ row }">
<span class="table-status" :class="row.statusClass"><i></i>{{ row.result }}</span>
</template>
<template #actions="{ row }">
<div class="row-actions">
<t-button variant="text" theme="primary" :disabled="row.statusClass === 'mission'" @click="goReplay(row)">{{ row.statusClass === 'mission' ? '执行中' : '轨迹回放' }}</t-button>
<t-button v-if="row.rawVideoId" variant="text" theme="primary" :disabled="row.rawVideoStatus !== 'ready'" @click="downloadRawVideo(row)">{{ row.rawVideoStatus === 'ready' ? '下载原视频' : '视频处理中' }}</t-button>
</div>
</template>
</t-table>
</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>
<t-button variant="outline" @click="openRouteEditor"><svg><use href="#i-plus" /></svg>新建航线</t-button>
</div>
<div class="route-grid">
<RouteCard
v-for="r in filteredRoutes"
:key="r.id"
:name="r.name"
:waypoints="r.waypoints"
:distance="r.distance"
:collected-at="r.collectedAt"
:map-class="r.mapClass"
/>
<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">
<t-button class="back-command" variant="text" type="button" @click="closeRouteEditor"><svg><use href="#i-chevron" /></svg>返回航线库</t-button>
<div><strong>新建航线</strong><span>{{ routeSummary }}</span></div>
<div><t-button variant="outline" type="button" :disabled="!routePoints.length" @click="undoRoutePoint"><svg><use href="#i-refresh" /></svg>撤销</t-button><t-button variant="outline" type="button" :disabled="!routePoints.length" @click="clearRoutePoints"><svg><use href="#i-x" /></svg>清空</t-button><t-button theme="primary" type="button" :disabled="routeSaving" @click="submitRoute">{{ routeSaving ? '保存中…' : '保存航线' }}</t-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><t-radio-group v-model="routeForm.turnMode" class="task-mode-radio" theme="button" variant="default-filled" :options="turnModeOptions" /></label>
<t-button class="apply-all-waypoints" variant="outline" type="button" :disabled="!routePoints.length" @click="applyRouteDefaults"><svg><use href="#i-settings" /></svg>应用到全部航点</t-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"><t-button variant="text" shape="square" type="button" title="删除航点" @click.stop="removeWaypoint(index)"><svg><use href="#i-x" /></svg></t-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>
<t-dialog
v-model:visible="createTaskOpen"
header="新建任务"
width="520px"
destroy-on-close
dialog-class-name="add-dock-dialog task-dialog"
placement="center"
>
<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>
<t-radio-group
class="task-mode-radio task-schedule-radio"
:value="scheduleMode"
theme="button"
variant="default-filled"
:options="scheduleModeOptions"
@change="setScheduleType"
/>
<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>
</form>
<template #footer>
<t-button variant="outline" type="button" @click="createTaskOpen = false">取消</t-button>
<t-button theme="primary" type="button" :disabled="creatingTask" @click="submitCreateTask">{{ creatingTask ? '创建中…' : '创建任务' }}</t-button>
</template>
</t-dialog>
<t-dialog
v-model:visible="waypointEditorOpen"
:header="`编辑航点 ${editingWaypointIndex + 1}`"
width="540px"
destroy-on-close
dialog-class-name="add-dock-dialog waypoint-dialog"
placement="center"
>
<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><t-radio-group v-model="waypointForm.turnMode" class="task-mode-radio" theme="button" variant="default-filled" :options="turnModeOptions" /></label>
<div class="route-form-error">{{ waypointFormError }}</div>
</form>
<template #footer>
<t-button variant="outline" type="button" @click="closeWaypointEditor">取消</t-button>
<t-button theme="primary" type="button" @click="saveWaypoint">保存航点</t-button>
</template>
</t-dialog>
</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'
import RouteCard from '@/components/RouteCard.vue'
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: '星期日' }
]
const turnModeOptions = [
{ label: '自动转弯', value: 'auto' },
{ label: '定点转弯', value: 'stop' }
]
const scheduleModeOptions = [
{ label: '手动执行', value: 'once' },
{ label: '每天', value: 'day' },
{ label: '每周', value: 'week' },
{ label: '自定义 Cron', value: 'custom' }
]
const scheduleMode = computed(() => {
if (taskForm.scheduleType === 'once') return 'once'
return taskForm.schedulePeriod || 'day'
})
const plansTabLabel = computed(() => `任务计划 ${plans.value.length}`)
const recordsTabLabel = computed(() => `执行记录 ${records.value.length}`)
const routesTabLabel = computed(() => `航线库 ${routes.value.length}`)
const planColumns = [
{ colKey: 'name', title: '任务名称' },
{ colKey: 'device', title: '执行设备' },
{ colKey: 'cycle', title: '执行方式' },
{ colKey: 'next', title: '下次执行' },
{ colKey: 'videoPolicy', title: '视频策略' },
{ colKey: 'status', title: '任务状态' },
{ colKey: 'lastResult', title: '最近结果' },
{ colKey: 'actions', title: '操作', width: '16%' }
]
const recordColumns = [
{ colKey: 'id', title: '执行编号' },
{ colKey: 'name', title: '任务名称' },
{ colKey: 'device', title: '执行设备' },
{ colKey: 'start', title: '开始时间' },
{ colKey: 'duration', title: '飞行时长' },
{ colKey: 'collect', title: '采集成果' },
{ colKey: 'result', title: '执行结果' },
{ colKey: 'actions', title: '操作', width: '18%' }
]
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) {
const ok = await ui.confirm(`确定永久删除任务“${task.name}”吗?`, '此操作不可恢复。')
if (!ok) 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;
}
.business-tdesign-tabs :deep(.t-tabs__nav-container) {
padding: 0 18px;
}
.business-tdesign-tabs :deep(.t-tabs__nav-wrap) {
margin-left: 0;
}
.business-table-wrap :deep(.t-table) {
background: transparent;
}
.task-mode-radio {
width: 100%;
display: flex;
}
.task-mode-radio :deep(.t-radio-button) {
flex: 1;
justify-content: center;
}
.task-schedule-radio :deep(.t-radio-button) {
min-width: 0;
padding: 0 4px;
white-space: nowrap;
}
.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>
<style>
.add-dock-dialog .t-input,
.add-dock-dialog .t-input-number,
.add-dock-dialog .t-select,
.add-dock-dialog .t-time-picker {
width: 100%;
}
</style>