@@ -231,6 +233,8 @@ import { useUserStore } from '@/stores/modules/userStore'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
import mapConfig from '@/config/map'
+import LivePlayer from '@/components/LivePlayer.vue'
+import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
const LAYER_ID = 'monitor-devices'
@@ -250,6 +254,7 @@ const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const selectedId = ref(null)
+const live = reactive({ session: null, dockId: '', playUrl: '', heartbeatTimer: null })
const filter = ref('all')
const search = ref('')
const controlExpanded = ref(false)
@@ -447,6 +452,7 @@ onMounted(async () => {
})
onUnmounted(() => {
+ closeLive()
teardownMap()
})
@@ -468,6 +474,67 @@ const selectedDrone = computed(() => (isDrone.value ? record.value : devices.dro
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 !== ''))
+const livePhaseText = computed(() => {
+ if (!live.session) return '未打开'
+ const phase = live.session.phase
+ if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播已就绪' : '直播中'
+ if (phase === 'starting') return '启动中'
+ if (phase === 'reconnecting') return '重连中'
+ if (phase === 'stopping') return '停止中'
+ if (phase === 'failed') return '启动失败'
+ return '已停止'
+})
+
+async function openLive() {
+ const dockId = selectedDock.value?.dockId
+ if (!dockId) return
+ try {
+ const result = await joinLive(dockId)
+ live.session = result.session
+ live.dockId = dockId
+ scheduleLiveHeartbeat(result.leaseExpiresAt)
+ if (result.session.phase === 'streaming') await refreshPlayURL()
+ } catch (e) {
+ ui.toast(e.message || '打开直播失败')
+ }
+}
+
+async function refreshPlayURL() {
+ if (!live.dockId) return
+ const play = await getLivePlayURL(live.dockId)
+ live.playUrl = play.playUrl || ''
+}
+
+function scheduleLiveHeartbeat(expiresAt) {
+ window.clearTimeout(live.heartbeatTimer)
+ const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
+ live.heartbeatTimer = window.setTimeout(async () => {
+ try {
+ const result = await heartbeatLive(live.dockId, live.session.id)
+ live.session = result.session
+ scheduleLiveHeartbeat(result.leaseExpiresAt)
+ if (result.session.phase === 'streaming' && !live.playUrl) await refreshPlayURL()
+ } catch (e) {
+ ui.toast(e.message || '直播观看已结束')
+ await closeLive(false)
+ }
+ }, delay)
+}
+
+async function closeLive(sendRequest = true) {
+ window.clearTimeout(live.heartbeatTimer)
+ const session = live.session
+ const dockId = live.dockId
+ live.session = null
+ live.dockId = ''
+ live.playUrl = ''
+ if (sendRequest && session && dockId) {
+ try {
+ await leaveLive(dockId, session.id)
+ } catch (_) {}
+ }
+}
+
function displayWithUnit(value, unit) {
return value == null || value === '' ? '--' : `${value}${unit}`
diff --git a/src/views/TasksView/TasksView.vue b/src/views/TasksView/TasksView.vue
index a8c54f2..2134b56 100644
--- a/src/views/TasksView/TasksView.vue
+++ b/src/views/TasksView/TasksView.vue
@@ -59,7 +59,7 @@
视频策略按任务配置
@@ -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 || '下载失败')
+ }
+}