From a4233f021046e859605154680afe272eb8604fee Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Thu, 20 Aug 2026 15:21:47 +0800 Subject: [PATCH] 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. --- src/api/live.js | 17 +++ src/components/LivePlayer.vue | 27 ++++ src/config/urls.js | 7 +- src/styles/prototype.css | 11 ++ src/utils/http.js | 4 +- src/views/DevicesView/DevicesView.vue | 15 ++ src/views/MediaView/MediaView.vue | 20 ++- src/views/MonitorView/MonitorView.vue | 73 +++++++++- src/views/TasksView/TasksView.vue | 201 ++++++++++++++++++++++---- 9 files changed, 337 insertions(+), 38 deletions(-) create mode 100644 src/api/live.js create mode 100644 src/components/LivePlayer.vue diff --git a/src/api/live.js b/src/api/live.js new file mode 100644 index 0000000..28cbe10 --- /dev/null +++ b/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`) +} diff --git a/src/components/LivePlayer.vue b/src/components/LivePlayer.vue new file mode 100644 index 0000000..237c602 --- /dev/null +++ b/src/components/LivePlayer.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/config/urls.js b/src/config/urls.js index 236d058..68dafbc 100644 --- a/src/config/urls.js +++ b/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` diff --git a/src/styles/prototype.css b/src/styles/prototype.css index 04d95cf..efc421a 100644 --- a/src/styles/prototype.css +++ b/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; } diff --git a/src/utils/http.js b/src/utils/http.js index ba928cb..3895cfd 100644 --- a/src/utils/http.js +++ b/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) } diff --git a/src/views/DevicesView/DevicesView.vue b/src/views/DevicesView/DevicesView.vue index 003ad22..b678354 100644 --- a/src/views/DevicesView/DevicesView.vue +++ b/src/views/DevicesView/DevicesView.vue @@ -88,6 +88,7 @@ + @@ -184,6 +185,7 @@ @@ -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 diff --git a/src/views/MediaView/MediaView.vue b/src/views/MediaView/MediaView.vue index f77ecbd..502b120 100644 --- a/src/views/MediaView/MediaView.vue +++ b/src/views/MediaView/MediaView.vue @@ -26,7 +26,7 @@
LIVE - + {{ s.source }}
@@ -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 { diff --git a/src/views/MonitorView/MonitorView.vue b/src/views/MonitorView/MonitorView.vue index 2dee417..2846b83 100644 --- a/src/views/MonitorView/MonitorView.vue +++ b/src/views/MonitorView/MonitorView.vue @@ -108,10 +108,12 @@

实时画面

- 暂无视频流 + {{ livePhaseText }}
-
- 暂无实时视频数据 + +
+ +
@@ -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 @@ {{ t.videoPolicy }} {{ t.status }} {{ t.lastResult }} -
+
@@ -71,7 +71,7 @@
- +
@@ -99,7 +99,7 @@ {{ r.duration }} {{ r.collect }} {{ r.result }} - +
@@ -183,6 +183,20 @@
+
+
执行方式按计划自动执行
+
+ + + + +
+
+ + +
+ +
视频策略按任务配置
@@ -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 || '下载失败') + } +}