Browse Source

feat: adapt main live/task/device flows to new structure

Port ece5594 device delete, live sessions, schedule tasks, and raw
video download onto View/ folder layout and centralized urls/http.
main
xiaosi 1 month ago
parent
commit
a4233f0210
  1. 17
      src/api/live.js
  2. 27
      src/components/LivePlayer.vue
  3. 7
      src/config/urls.js
  4. 11
      src/styles/prototype.css
  5. 4
      src/utils/http.js
  6. 15
      src/views/DevicesView/DevicesView.vue
  7. 20
      src/views/MediaView/MediaView.vue
  8. 73
      src/views/MonitorView/MonitorView.vue
  9. 199
      src/views/TasksView/TasksView.vue

17
src/api/live.js

@ -0,0 +1,17 @@
import request from '@/utils/http'
export function joinLive(dockId, payload = {}) {
return request.post(`/v1/live/${dockId}/sessions`, payload)
}
export function getLivePlayURL(dockId) {
return request.get(`/v1/live/${dockId}/play-url`)
}
export function heartbeatLive(dockId, sessionId) {
return request.post(`/v1/live/${dockId}/sessions/${sessionId}/heartbeat`)
}
export function leaveLive(dockId, sessionId) {
return request.delete(`/v1/live/${dockId}/sessions/${sessionId}/viewers/me`)
}

27
src/components/LivePlayer.vue

@ -0,0 +1,27 @@
<template>
<div class="live-player">
<video v-if="playable" ref="video" controls autoplay playsinline :src="playUrl" @error="$emit('error')"></video>
<div v-else class="video-empty">
<span>{{ message }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
playUrl: { type: String, default: '' },
phase: { type: String, default: '' }
})
defineEmits(['error'])
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://'))
const message = computed(() => {
if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪'
if (props.phase === 'failed') return '直播启动失败'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证,未配置本地媒体流'
return '暂无实时视频数据'
})
</script>

7
src/config/urls.js

@ -19,7 +19,8 @@ export const DRONE = (id) => `/v1/drones/${id}`
// Tasks
export const TASKS = '/v1/tasks'
export const TASK = (id) => `/v1/tasks/${id}`
export const TASK_EXECUTE = (id) => `/v1/tasks/${id}/execute`
// Executions
export const EXECUTIONS = '/v1/executions'
export const EXECUTION = (key) => `/v1/executions/${key}`
@ -30,6 +31,10 @@ export const ROUTES = '/v1/routes'
// Live / Videos
export const LIVE = '/v1/live'
export const LIVE_SESSIONS = (dockId) => `/v1/live/${dockId}/sessions`
export const LIVE_PLAY_URL = (dockId) => `/v1/live/${dockId}/play-url`
export const LIVE_HEARTBEAT = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/heartbeat`
export const LIVE_LEAVE = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/viewers/me`
export const VIDEOS = '/v1/videos'
export const VIDEO_DOWNLOAD = (id) => `/v1/videos/${id}/download`

11
src/styles/prototype.css

@ -418,6 +418,17 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.task-schedule-fields[hidden] { display: none; }.task-schedule-fields { margin-top: 2px; }
.weekday-picker { height: 38px; padding: 3px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 3px; border-radius: 4px; background: #edf1f4; }
.weekday-picker button { min-width: 0; border-radius: 3px; color: #65737f; background: transparent; font-size: 11px; }.weekday-picker button:hover { color: #2869aa; }.weekday-picker button.active { color: #fff; background: #2c72bd; box-shadow: 0 1px 4px rgba(23,66,108,.18); }
.task-dialog .mode-segment { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.task-dialog .mode-segment button { min-width: 0; padding: 0 4px; white-space: nowrap; }
.task-dialog .task-schedule-settings { margin-bottom: 3px; }
.task-dialog .task-schedule-settings > label:last-child { margin-bottom: 12px; }
.task-dialog .task-schedule-settings label small { color: var(--muted); font-size: 10px; line-height: 1.4; }
.task-dialog .task-schedule-settings input[type='text'] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width: 560px) {
.task-dialog .mode-segment { grid-template-columns: repeat(2, minmax(0, 1fr)); height: auto; min-height: 72px; }
.task-dialog .mode-segment button { min-height: 30px; }
}
.task-video-settings { margin: 3px 0 12px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.task-video-title { height: 35px; display: flex; align-items: center; justify-content: space-between; }.task-video-title strong { font-size: 12px; }.task-video-title span { color: var(--muted); font-size: 10px; }
.add-dock-dialog .task-video-option { min-height: 48px; margin: 0; padding: 7px 2px; display: grid; grid-template-columns: 1fr 34px; align-items: center; gap: 10px; border-top: 1px solid #edf0f2; cursor: pointer; }

4
src/utils/http.js

@ -36,7 +36,9 @@ request.interceptors.response.use(
} else if (status === 403) {
useUiStore().toast(data?.msg || '无权限访问')
} else {
useUiStore().toast(data?.msg || error.message || '网络错误')
const message = data?.msg || error.message || '网络错误'
useUiStore().toast(message)
return Promise.reject(new Error(message))
}
return Promise.reject(error)
}

15
src/views/DevicesView/DevicesView.vue

@ -88,6 +88,7 @@
<button v-if="!isAdmin" @click.stop="openFirmwareUpgrade(d.id)">固件升级</button>
<button v-if="!isAdmin" @click.stop="goControl(d.id)">远程控制</button>
<button @click.stop="goDetail(d.id)">详情</button>
<button v-if="!isAdmin" @click.stop="deleteDock(d)">删除</button>
</div>
</td>
</tr>
@ -184,6 +185,7 @@
</section>
<footer>
<button v-if="!isAdmin && drawerRecord.kind === 'dock'" class="plain-command danger-command" @click="deleteDock(drawerRecord)">删除机巢</button>
<button v-if="!isAdmin && drawerRecord.kind === 'dock'" class="plain-command" @click="openEditDock(drawerRecord)">编辑信息</button>
<button class="primary-command" @click="goDetail(drawerRecord.id)">查看完整详情</button>
</footer>
@ -403,6 +405,19 @@ function openEditDock(dock) {
editDockRecord.value = dock
editDockVisible.value = true
}
async function deleteDock(dock) {
const ok = await ui.confirm(`确认硬删除机巢「${dock.name}」?`, '删除后机巢及其关联设备数据将永久移除,且无法恢复。')
if (!ok) return
try {
await request.delete(urls.DOCK(dock.id))
drawerOpen.value = false
ui.toast('机巢已永久删除')
await devices.load()
} catch (e) {
ui.toast(e.message || '删除机巢失败')
}
}
async function handleDockSaved() {
editDockVisible.value = false

20
src/views/MediaView/MediaView.vue

@ -26,7 +26,7 @@
<article v-for="s in liveStreams" :key="s.id">
<div class="media-visual" :class="s.visualClass">
<span class="live-corner"><i></i>LIVE</span>
<button @click="ui.toast('打开直播:' + s.name)"><svg><use href="#i-play" /></svg></button>
<button @click="openLive(s)"><svg><use href="#i-play" /></svg></button>
<small>{{ s.source }}</small>
</div>
<div class="media-card-copy">
@ -109,8 +109,9 @@ async function load() {
request.get(urls.LIVE, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } })
])
liveStreams.value = (livePage?.records || []).map((s) => ({
liveStreams.value = (livePage?.records || []).filter((s) => ['starting', 'streaming', 'reconnecting'].includes(s.phase)).map((s) => ({
id: s.id,
dockId: s.dockId,
name: s.dockId,
desc: s.streamName ? `${s.streamName} · ${s.provider}` : s.provider,
source: s.streamName || '--',
@ -129,6 +130,21 @@ async function load() {
}
onMounted(load)
async function openLive(stream) {
try {
const result = await request.post(urls.LIVE_SESSIONS(stream.dockId))
if (result?.session?.phase === 'streaming') {
const play = await request.get(urls.LIVE_PLAY_URL(stream.dockId))
if (play?.playUrl && !play.playUrl.startsWith('fake://')) window.open(play.playUrl, '_blank')
else ui.toast('控制面已验证,当前未配置可播放媒体流')
} else {
ui.toast('直播正在启动,请稍后重试')
}
} catch (e) {
ui.toast(e.message || '打开直播失败')
}
}
async function download(v) {
try {

73
src/views/MonitorView/MonitorView.vue

@ -108,10 +108,12 @@
<section v-if="!isDrone" class="video-section">
<div class="section-title">
<h3>实时画面</h3>
<span class="video-unavailable">暂无视频流</span>
<span class="video-unavailable">{{ livePhaseText }}</span>
</div>
<div class="video-frame video-empty">
<span>暂无实时视频数据</span>
<LivePlayer :play-url="live.playUrl" :phase="live.session?.phase" @error="ui.toast('视频播放失败')" />
<div class="video-actions">
<button v-if="!live.session" type="button" @click="openLive">打开实时画面</button>
<button v-else type="button" @click="closeLive">关闭实时画面</button>
</div>
</section>
@ -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}`

199
src/views/TasksView/TasksView.vue

@ -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
})
@ -715,8 +832,13 @@ async function submitCreateTask() {
taskFormError.value = '请完整填写任务名称、执行机巢和飞行航线。'
return
}
if (taskForm.scheduleType === 'cron' && !taskForm.scheduleCron.trim()) {
taskFormError.value = '请填写定时执行表达式。'
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
}
@ -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>

Loading…
Cancel
Save