|
|
|
@ -59,7 +59,7 @@ |
|
|
|
<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> |
|
|
|
<td><div class="row-actions"><button v-if="canExecuteTask(t)" @click="executeTask(t)">立即执行</button><button @click="deleteTask(t)">删除</button></div></td> |
|
|
|
</tr> |
|
|
|
</tbody> |
|
|
|
</table> |
|
|
|
@ -71,7 +71,7 @@ |
|
|
|
<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> |
|
|
|
<select v-model="execFilter"><option value="">全部设备</option><option v-for="d in devices" :key="d.id" :value="d.dockId || d.droneSn">{{ d.name }}</option></select> |
|
|
|
<div class="business-search"><svg><use href="#i-search" /></svg><input v-model="execSearch" type="text" placeholder="搜索执行记录" /></div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -99,7 +99,7 @@ |
|
|
|
<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> |
|
|
|
<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> |
|
|
|
@ -183,6 +183,20 @@ |
|
|
|
</select> |
|
|
|
</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><select v-model.number="taskForm.scheduleWeekday"><option v-for="day in weekdays" :key="day.value" :value="day.value">{{ day.label }}</option></select></label> |
|
|
|
<label><span>执行时间</span><input v-model="taskForm.scheduleTime" type="time" /></label> |
|
|
|
</div> |
|
|
|
<label v-if="taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom'"><span>Cron 表达式<b>*</b></span><input v-model.trim="taskForm.scheduleCron" type="text" 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><input v-model="taskForm.rawRecordingEnabled" type="checkbox" /><i></i></label> |
|
|
|
@ -257,6 +271,7 @@ const waypointForm = reactive({ longitude: 0, latitude: 0, altitude: 50, speed: |
|
|
|
|
|
|
|
const plans = ref([]) |
|
|
|
const records = ref([]) |
|
|
|
let refreshTimer = null |
|
|
|
const routes = ref([]) |
|
|
|
const devices = computed(() => [...deviceStore.drones, ...deviceStore.docks]) |
|
|
|
|
|
|
|
@ -268,10 +283,23 @@ const taskForm = reactive({ |
|
|
|
dockId: '', |
|
|
|
routeId: '', |
|
|
|
scheduleType: 'once', |
|
|
|
schedulePeriod: 'day', |
|
|
|
scheduleWeekday: 1, |
|
|
|
scheduleTime: '08:00', |
|
|
|
scheduleCron: '', |
|
|
|
rawRecordingEnabled: false |
|
|
|
}) |
|
|
|
|
|
|
|
const weekdays = [ |
|
|
|
{ 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 } |
|
|
|
@ -285,18 +313,32 @@ const TASK_STATUS = { |
|
|
|
enabled: { status: '待执行', statusClass: 'pending' }, |
|
|
|
running: { status: '执行中', statusClass: 'mission' }, |
|
|
|
paused: { status: '已暂停', statusClass: 'offline' }, |
|
|
|
draft: { status: '已暂停', statusClass: 'offline' }, |
|
|
|
draft: { status: '待执行', statusClass: 'pending' }, |
|
|
|
disabled: { status: '已暂停', statusClass: 'offline' } |
|
|
|
} |
|
|
|
|
|
|
|
const EXEC_STATUS = { |
|
|
|
pending: '待执行', |
|
|
|
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) |
|
|
|
@ -304,49 +346,59 @@ function fmtTime(t) { |
|
|
|
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] = await Promise.all([ |
|
|
|
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.ROUTES, { params: { pageNum: 1, pageSize: 100 } }), |
|
|
|
request.get(urls.VIDEOS, { 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 || '--') : '待手动执行', |
|
|
|
cycle: formatSchedule(t), |
|
|
|
next: t.scheduleType === 'cron' ? formatSchedule(t) : '待手动执行', |
|
|
|
videoPolicy: t.videoPolicy === 'raw' ? '原始录制' : '未启用', |
|
|
|
videoPolicyClass: t.videoPolicy === 'raw' ? 'record' : 'none', |
|
|
|
status: st.status, |
|
|
|
statusClass: st.statusClass, |
|
|
|
lastResult: '尚未执行' |
|
|
|
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 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' |
|
|
|
} |
|
|
|
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) => ({ |
|
|
|
@ -357,6 +409,12 @@ async function load() { |
|
|
|
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 || '加载任务数据失败') |
|
|
|
} |
|
|
|
@ -365,6 +423,7 @@ async function load() { |
|
|
|
onMounted(load) |
|
|
|
|
|
|
|
onUnmounted(() => { |
|
|
|
if (refreshTimer) window.clearInterval(refreshTimer) |
|
|
|
teardownRouteMap() |
|
|
|
}) |
|
|
|
|
|
|
|
@ -690,6 +749,61 @@ function resetPlanFilter() { |
|
|
|
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) |
|
|
|
@ -701,6 +815,9 @@ function openCreateTask() { |
|
|
|
dockId: deviceStore.docks[0]?.dockId || '', |
|
|
|
routeId: '', |
|
|
|
scheduleType: 'once', |
|
|
|
schedulePeriod: 'day', |
|
|
|
scheduleWeekday: 1, |
|
|
|
scheduleTime: '08:00', |
|
|
|
scheduleCron: '', |
|
|
|
rawRecordingEnabled: false |
|
|
|
}) |
|
|
|
@ -712,11 +829,16 @@ async function submitCreateTask() { |
|
|
|
taskFormError.value = '' |
|
|
|
const name = taskForm.name.trim() |
|
|
|
if (!name || !taskForm.dockId || !taskForm.routeId) { |
|
|
|
taskFormError.value = '请完整填写任务名称、执行机巢和飞行航线。' |
|
|
|
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' && !taskForm.scheduleCron.trim()) { |
|
|
|
taskFormError.value = '请填写定时执行表达式。' |
|
|
|
if (taskForm.scheduleType === 'cron' && !scheduleCron) { |
|
|
|
taskFormError.value = '请选择有效的执行时间。' |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
@ -726,6 +848,8 @@ async function submitCreateTask() { |
|
|
|
name, |
|
|
|
dockId: taskForm.dockId, |
|
|
|
routeId: Number(taskForm.routeId) || 0, |
|
|
|
scheduleType: taskForm.scheduleType, |
|
|
|
scheduleCron, |
|
|
|
videoPolicy: taskForm.rawRecordingEnabled ? 'raw' : 'replay' |
|
|
|
}) |
|
|
|
createTaskOpen.value = false |
|
|
|
@ -741,4 +865,19 @@ async function submitCreateTask() { |
|
|
|
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> |
|
|
|
|