| | |
| | | <div> |
| | | <h3>任务执行监控</h3> |
| | | <p>实时查看最新的多设备任务</p> |
| | | <p v-if="sseConnected" class="sse-status connected"> |
| | | <el-icon><Connection /></el-icon> |
| | | 实时监控已连接 |
| | | </p> |
| | | <p v-else class="sse-status disconnected"> |
| | | <el-icon><Close /></el-icon> |
| | | 实时监控未连接 |
| | | </p> |
| | | </div> |
| | | <el-button :loading="loading" @click="fetchTasks"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <div class="action-buttons"> |
| | | <el-button :loading="loading" @click="fetchTasks"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <el-button |
| | | v-if="!sseConnected" |
| | | type="success" |
| | | @click="connectSSE" |
| | | :loading="sseConnecting" |
| | | > |
| | | <el-icon><VideoPlay /></el-icon> |
| | | 开启实时监控 |
| | | </el-button> |
| | | <el-button v-else type="danger" @click="disconnectSSE"> |
| | | <el-icon><VideoPause /></el-icon> |
| | | 关闭实时监控 |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-table |
| | |
| | | height="300" |
| | | stripe |
| | | @row-click="handleRowClick" |
| | | row-key="taskId" |
| | | > |
| | | <el-table-column prop="taskId" label="任务编号" min-width="160" /> |
| | | <el-table-column prop="groupId" label="设备组ID" width="120" /> |
| | | <el-table-column prop="status" label="状态" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="statusType(row.status)">{{ row.status }}</el-tag> |
| | | <el-tag :type="statusType(row.status)"> |
| | | {{ getStatusLabel(row.status) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="currentStep" label="进度" width="120"> |
| | | <el-table-column prop="currentStep" label="进度" width="140"> |
| | | <template #default="{ row }"> |
| | | {{ row.currentStep || 0 }} / {{ row.totalSteps || 0 }} |
| | | <div class="progress-cell"> |
| | | <span>{{ row.currentStep || 0 }} / {{ row.totalSteps || 0 }}</span> |
| | | <el-progress |
| | | :percentage="getProgressPercentage(row)" |
| | | :status="getProgressStatus(row.status)" |
| | | :stroke-width="6" |
| | | :show-text="false" |
| | | style="margin-top: 4px;" |
| | | /> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="开始时间" min-width="160" prop="startTime" /> |
| | | <el-table-column label="结束时间" min-width="160" prop="endTime" /> |
| | | <el-table-column label="开始时间" min-width="160"> |
| | | <template #default="{ row }"> |
| | | {{ formatDateTime(row.startTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="结束时间" min-width="160"> |
| | | <template #default="{ row }"> |
| | | {{ formatDateTime(row.endTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="120" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | link |
| | | type="primary" |
| | | size="small" |
| | | @click.stop="handleRowClick(row)" |
| | | > |
| | | 查看详情 |
| | | </el-button> |
| | | <el-button |
| | | v-if="row.status === 'RUNNING' || row.status === 'FAILED'" |
| | | link |
| | | type="danger" |
| | | size="small" |
| | | @click.stop="handleCancelTask(row)" |
| | | > |
| | | 取消 |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <el-drawer v-model="drawerVisible" size="40%" title="任务步骤详情"> |
| | | <el-timeline v-loading="stepsLoading" :reverse="false"> |
| | | <el-drawer v-model="drawerVisible" size="40%" :title="`任务步骤详情 - ${currentTaskId || ''}`"> |
| | | <div class="drawer-header" v-if="currentTask"> |
| | | <el-descriptions :column="2" border size="small"> |
| | | <el-descriptions-item label="任务状态"> |
| | | <el-tag :type="statusType(currentTask.status)"> |
| | | {{ getStatusLabel(currentTask.status) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="进度"> |
| | | {{ currentTask.currentStep || 0 }} / {{ currentTask.totalSteps || 0 }} |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | </div> |
| | | <el-timeline v-loading="stepsLoading" :reverse="false" style="margin-top: 20px;"> |
| | | <el-timeline-item |
| | | v-for="step in steps" |
| | | :key="step.id" |
| | | :timestamp="step.startTime || '-'" |
| | | :type="step.status === 'COMPLETED' ? 'success' : step.status === 'FAILED' ? 'danger' : 'primary'" |
| | | :timestamp="formatDateTime(step.startTime) || '-'" |
| | | :type="getStepTimelineType(step.status)" |
| | | > |
| | | <div class="step-title">{{ step.stepName }}</div> |
| | | <div class="step-desc">状态:{{ step.status }}</div> |
| | | <div class="step-desc"> |
| | | <el-tag :type="getStepStatusType(step.status)" size="small"> |
| | | {{ getStepStatusLabel(step.status) }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="step-desc">耗时:{{ formatDuration(step.durationMs) }}</div> |
| | | <div class="step-desc" v-if="step.errorMessage"> |
| | | <div class="step-desc" v-if="step.retryCount > 0"> |
| | | 重试次数:{{ step.retryCount }} |
| | | </div> |
| | | <div class="step-desc error-message" v-if="step.errorMessage"> |
| | | <el-icon><Warning /></el-icon> |
| | | 错误:{{ step.errorMessage }} |
| | | </div> |
| | | </el-timeline-item> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { onMounted, ref, watch } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { Refresh } from '@element-plus/icons-vue' |
| | | import { onMounted, onUnmounted, ref, watch } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { |
| | | Refresh, |
| | | Connection, |
| | | Close, |
| | | VideoPlay, |
| | | VideoPause, |
| | | Warning |
| | | } from '@element-plus/icons-vue' |
| | | import { multiDeviceTaskApi } from '@/api/device/multiDeviceTask' |
| | | |
| | | const props = defineProps({ |
| | | groupId: { |
| | | type: [String, Number], |
| | | default: null |
| | | }, |
| | | taskId: { |
| | | type: String, |
| | | default: null |
| | | } |
| | | }) |
| | |
| | | const stepsLoading = ref(false) |
| | | const steps = ref([]) |
| | | const currentTaskId = ref(null) |
| | | const currentTask = ref(null) |
| | | |
| | | // SSE相关 |
| | | const sseConnected = ref(false) |
| | | const sseConnecting = ref(false) |
| | | let eventSource = null |
| | | const baseURL = import.meta.env.VITE_API_BASE_URL || '' |
| | | |
| | | const fetchTasks = async () => { |
| | | try { |
| | |
| | | } |
| | | } |
| | | |
| | | // SSE连接 |
| | | const connectSSE = () => { |
| | | if (eventSource) { |
| | | disconnectSSE() |
| | | } |
| | | |
| | | sseConnecting.value = true |
| | | try { |
| | | // 构建SSE URL - 后端只支持 taskId 参数,不支持 groupId |
| | | let url = `${baseURL}/api/plcSend/task/notification/sse` |
| | | // 如果没有指定 taskId,则监听所有任务(不传参数) |
| | | if (props.taskId) { |
| | | url += `?taskId=${encodeURIComponent(props.taskId)}` |
| | | } |
| | | // 注意:后端不支持 groupId 参数,如果需要监听某个组的所有任务, |
| | | // 需要在前端根据 groupId 获取任务列表,然后为每个任务创建连接 |
| | | // 或者使用不传参数的方式监听所有任务,然后在前端过滤 |
| | | |
| | | eventSource = new EventSource(url) |
| | | |
| | | eventSource.onopen = () => { |
| | | sseConnected.value = true |
| | | sseConnecting.value = false |
| | | ElMessage.success('实时监控已连接') |
| | | } |
| | | |
| | | eventSource.onerror = (error) => { |
| | | console.error('SSE连接错误:', error) |
| | | sseConnected.value = false |
| | | sseConnecting.value = false |
| | | if (eventSource?.readyState === EventSource.CLOSED) { |
| | | ElMessage.warning('实时监控连接已断开') |
| | | // 尝试重连 |
| | | setTimeout(() => { |
| | | if (!sseConnected.value) { |
| | | connectSSE() |
| | | } |
| | | }, 3000) |
| | | } |
| | | } |
| | | |
| | | // 监听连接成功事件 |
| | | eventSource.addEventListener('connected', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | console.log('SSE连接成功:', data) |
| | | } catch (error) { |
| | | console.error('解析连接消息失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听任务状态更新 |
| | | eventSource.addEventListener('taskStatus', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | // 如果指定了 groupId,只更新该组的任务 |
| | | if (!props.groupId || !data.groupId || String(data.groupId) === String(props.groupId)) { |
| | | updateTaskFromSSE(data) |
| | | } |
| | | } catch (error) { |
| | | console.error('解析任务状态失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听步骤更新 |
| | | eventSource.addEventListener('stepUpdate', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | // 如果数据中包含 taskId,检查是否匹配当前查看的任务 |
| | | if (data.taskId && data.taskId === currentTaskId.value) { |
| | | updateStepFromSSE(data) |
| | | } else if (!data.taskId) { |
| | | // 如果没有 taskId,可能是步骤数据直接传递 |
| | | updateStepFromSSE(data) |
| | | } |
| | | } catch (error) { |
| | | console.error('解析步骤更新失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听步骤列表更新 |
| | | eventSource.addEventListener('stepsUpdate', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | if (data.taskId === currentTaskId.value && Array.isArray(data.steps)) { |
| | | steps.value = data.steps |
| | | } |
| | | } catch (error) { |
| | | console.error('解析步骤列表失败:', error) |
| | | } |
| | | }) |
| | | } catch (error) { |
| | | console.error('创建SSE连接失败:', error) |
| | | ElMessage.error('连接实时监控失败: ' + error.message) |
| | | sseConnecting.value = false |
| | | } |
| | | } |
| | | |
| | | const disconnectSSE = () => { |
| | | if (eventSource) { |
| | | eventSource.close() |
| | | eventSource = null |
| | | } |
| | | sseConnected.value = false |
| | | sseConnecting.value = false |
| | | } |
| | | |
| | | // 从SSE更新任务状态 |
| | | const updateTaskFromSSE = (data) => { |
| | | if (!data || !data.taskId) return |
| | | |
| | | // 如果指定了 groupId,只处理该组的任务 |
| | | if (props.groupId && data.groupId && String(data.groupId) !== String(props.groupId)) { |
| | | return |
| | | } |
| | | |
| | | const taskIndex = tasks.value.findIndex(t => t.taskId === data.taskId) |
| | | if (taskIndex >= 0) { |
| | | // 更新任务 - 保留原有字段,只更新SSE传来的字段 |
| | | const existingTask = tasks.value[taskIndex] |
| | | tasks.value[taskIndex] = { |
| | | ...existingTask, |
| | | status: data.status || existingTask.status, |
| | | currentStep: data.currentStep !== undefined ? data.currentStep : existingTask.currentStep, |
| | | totalSteps: data.totalSteps !== undefined ? data.totalSteps : existingTask.totalSteps, |
| | | startTime: data.startTime ? new Date(data.startTime) : existingTask.startTime, |
| | | endTime: data.endTime ? new Date(data.endTime) : existingTask.endTime, |
| | | errorMessage: data.errorMessage || existingTask.errorMessage |
| | | } |
| | | // 如果当前查看的是这个任务,也更新 |
| | | if (currentTaskId.value === data.taskId) { |
| | | currentTask.value = tasks.value[taskIndex] |
| | | } |
| | | } else { |
| | | // 新任务,添加到列表(需要转换时间戳为Date对象) |
| | | const newTask = { |
| | | ...data, |
| | | startTime: data.startTime ? new Date(data.startTime) : null, |
| | | endTime: data.endTime ? new Date(data.endTime) : null |
| | | } |
| | | tasks.value.unshift(newTask) |
| | | } |
| | | } |
| | | |
| | | // 从SSE更新步骤 |
| | | const updateStepFromSSE = (data) => { |
| | | if (!data) return |
| | | |
| | | // 如果数据中包含 taskId,检查是否匹配当前查看的任务 |
| | | if (data.taskId && data.taskId !== currentTaskId.value) { |
| | | return |
| | | } |
| | | |
| | | // 如果当前没有打开任务详情,不更新步骤 |
| | | if (!currentTaskId.value) { |
| | | return |
| | | } |
| | | |
| | | // 使用 id 或 stepOrder 来查找步骤 |
| | | const stepIndex = data.id |
| | | ? steps.value.findIndex(s => s.id === data.id) |
| | | : data.stepOrder !== undefined |
| | | ? steps.value.findIndex(s => s.stepOrder === data.stepOrder) |
| | | : -1 |
| | | |
| | | if (stepIndex >= 0) { |
| | | // 更新步骤 - 保留原有字段,只更新SSE传来的字段 |
| | | const existingStep = steps.value[stepIndex] |
| | | steps.value[stepIndex] = { |
| | | ...existingStep, |
| | | status: data.status || existingStep.status, |
| | | startTime: data.startTime ? new Date(data.startTime) : existingStep.startTime, |
| | | endTime: data.endTime ? new Date(data.endTime) : existingStep.endTime, |
| | | durationMs: data.durationMs !== undefined ? data.durationMs : existingStep.durationMs, |
| | | retryCount: data.retryCount !== undefined ? data.retryCount : existingStep.retryCount, |
| | | errorMessage: data.errorMessage || existingStep.errorMessage |
| | | } |
| | | } else if (data.stepOrder !== undefined) { |
| | | // 新步骤,添加到列表(需要转换时间戳为Date对象) |
| | | const newStep = { |
| | | ...data, |
| | | startTime: data.startTime ? new Date(data.startTime) : null, |
| | | endTime: data.endTime ? new Date(data.endTime) : null |
| | | } |
| | | steps.value.push(newStep) |
| | | // 按 stepOrder 排序 |
| | | steps.value.sort((a, b) => (a.stepOrder || 0) - (b.stepOrder || 0)) |
| | | } |
| | | } |
| | | |
| | | const handleCancelTask = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确定要取消任务 ${row.taskId} 吗?`, |
| | | '确认取消', |
| | | { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | } |
| | | ) |
| | | await multiDeviceTaskApi.cancelTask(row.taskId) |
| | | ElMessage.success('任务已取消') |
| | | fetchTasks() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '取消任务失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const emit = defineEmits(['task-selected']) |
| | | |
| | | const handleRowClick = async (row) => { |
| | | currentTaskId.value = row.taskId |
| | | currentTask.value = row |
| | | emit('task-selected', row) |
| | | drawerVisible.value = true |
| | | stepsLoading.value = true |
| | | try { |
| | |
| | | } |
| | | } |
| | | |
| | | // 根据taskId打开任务详情抽屉(供父组件调用) |
| | | const openTaskDrawer = async (taskId) => { |
| | | if (!taskId) return |
| | | // 如果任务列表为空,先加载一次 |
| | | if (!tasks.value || tasks.value.length === 0) { |
| | | await fetchTasks() |
| | | } |
| | | const task = tasks.value.find(t => t.taskId === taskId) |
| | | if (!task) { |
| | | return |
| | | } |
| | | await handleRowClick(task) |
| | | } |
| | | |
| | | const statusType = (status) => { |
| | | switch ((status || '').toUpperCase()) { |
| | | case 'COMPLETED': |
| | |
| | | return 'danger' |
| | | case 'RUNNING': |
| | | return 'warning' |
| | | case 'PENDING': |
| | | return 'info' |
| | | case 'CANCELLED': |
| | | return 'info' |
| | | default: |
| | | return 'info' |
| | | } |
| | | } |
| | | |
| | | const getStatusLabel = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'CANCELLED': '已取消' |
| | | } |
| | | return statusMap[s] || s || '未知' |
| | | } |
| | | |
| | | const getProgressPercentage = (row) => { |
| | | if (!row.totalSteps || row.totalSteps === 0) return 0 |
| | | return Math.round(((row.currentStep || 0) / row.totalSteps) * 100) |
| | | } |
| | | |
| | | const getProgressStatus = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'exception' |
| | | if (s === 'RUNNING') return 'active' |
| | | return null |
| | | } |
| | | |
| | | const getStepTimelineType = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'danger' |
| | | if (s === 'RUNNING') return 'primary' |
| | | return 'info' |
| | | } |
| | | |
| | | const getStepStatusType = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'danger' |
| | | if (s === 'RUNNING') return 'warning' |
| | | if (s === 'PENDING') return 'info' |
| | | return 'default' |
| | | } |
| | | |
| | | const getStepStatusLabel = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'SKIPPED': '已跳过' |
| | | } |
| | | return statusMap[s] || s || '未知' |
| | | } |
| | | |
| | | const formatDuration = (ms) => { |
| | |
| | | return `${(ms / 1000).toFixed(1)} s` |
| | | } |
| | | |
| | | // 格式化日期时间 |
| | | const formatDateTime = (dateTime) => { |
| | | if (!dateTime) return '-' |
| | | try { |
| | | const date = new Date(dateTime) |
| | | // 检查日期是否有效 |
| | | if (isNaN(date.getTime())) { |
| | | return dateTime // 如果无法解析,返回原始值 |
| | | } |
| | | const year = date.getFullYear() |
| | | const month = String(date.getMonth() + 1).padStart(2, '0') |
| | | const day = String(date.getDate()).padStart(2, '0') |
| | | const hours = String(date.getHours()).padStart(2, '0') |
| | | const minutes = String(date.getMinutes()).padStart(2, '0') |
| | | const seconds = String(date.getSeconds()).padStart(2, '0') |
| | | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` |
| | | } catch (error) { |
| | | console.warn('格式化时间失败:', dateTime, error) |
| | | return dateTime |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => props.groupId, |
| | | () => { |
| | | fetchTasks() |
| | | // 如果SSE已连接,重新连接(因为监听所有任务,前端会过滤) |
| | | if (sseConnected.value) { |
| | | disconnectSSE() |
| | | // 延迟重连,避免频繁连接 |
| | | setTimeout(() => { |
| | | connectSSE() |
| | | }, 500) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | onMounted(fetchTasks) |
| | | watch( |
| | | () => props.taskId, |
| | | () => { |
| | | // 如果指定了 taskId,重新连接以监听特定任务 |
| | | if (sseConnected.value) { |
| | | disconnectSSE() |
| | | setTimeout(() => { |
| | | connectSSE() |
| | | }, 500) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | onMounted(() => { |
| | | fetchTasks() |
| | | // 自动连接SSE |
| | | connectSSE() |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | disconnectSSE() |
| | | }) |
| | | |
| | | defineExpose({ |
| | | fetchTasks |
| | | fetchTasks, |
| | | connectSSE, |
| | | disconnectSSE, |
| | | openTaskDrawer |
| | | }) |
| | | </script> |
| | | |
| | |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .sse-status { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .sse-status.connected { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .sse-status.disconnected { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .progress-cell { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .drawer-header { |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .step-title { |
| | | font-weight: 600; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .step-desc { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .step-desc.error-message { |
| | | color: #f56c6c; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .step-title { |
| | | font-weight: 600; |
| | | margin-bottom: 4px; |