<template>
|
<div class="execution-monitor">
|
<div class="panel-header">
|
<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>
|
<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
|
v-loading="loading"
|
:data="tasks"
|
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)">
|
{{ getStatusLabel(row.status) }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="currentStep" label="进度" width="140">
|
<template #default="{ row }">
|
<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">
|
<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="`任务步骤详情 - ${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="formatDateTime(step.startTime) || '-'"
|
:type="getStepTimelineType(step.status)"
|
>
|
<div class="step-title">{{ step.stepName }}</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.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>
|
</el-timeline>
|
</el-drawer>
|
</div>
|
</template>
|
|
<script setup>
|
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 loading = ref(false)
|
const tasks = ref([])
|
const drawerVisible = ref(false)
|
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 {
|
loading.value = true
|
const { data } = await multiDeviceTaskApi.getTaskList({
|
groupId: props.groupId,
|
page: 1,
|
size: 10
|
})
|
tasks.value = data?.records || data?.data || data || []
|
} catch (error) {
|
ElMessage.error(error?.message || '加载任务列表失败')
|
} finally {
|
loading.value = false
|
}
|
}
|
|
// 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 {
|
const { data } = await multiDeviceTaskApi.getTaskSteps(row.taskId)
|
steps.value = Array.isArray(data) ? data : (data?.data || [])
|
} catch (error) {
|
ElMessage.error(error?.message || '加载任务步骤失败')
|
} finally {
|
stepsLoading.value = false
|
}
|
}
|
|
// 根据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 'success'
|
case 'FAILED':
|
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) => {
|
if (!ms) return '-'
|
if (ms < 1000) return `${ms} 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 }
|
)
|
|
watch(
|
() => props.taskId,
|
() => {
|
// 如果指定了 taskId,重新连接以监听特定任务
|
if (sseConnected.value) {
|
disconnectSSE()
|
setTimeout(() => {
|
connectSSE()
|
}, 500)
|
}
|
}
|
)
|
|
onMounted(() => {
|
fetchTasks()
|
// 自动连接SSE
|
connectSSE()
|
})
|
|
onUnmounted(() => {
|
disconnectSSE()
|
})
|
|
defineExpose({
|
fetchTasks,
|
connectSSE,
|
disconnectSSE,
|
openTaskDrawer
|
})
|
</script>
|
|
<style scoped>
|
.execution-monitor {
|
background: #fff;
|
border-radius: 12px;
|
padding: 20px;
|
box-shadow: 0 8px 32px rgba(15, 18, 63, 0.08);
|
}
|
|
.panel-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 16px;
|
}
|
|
.panel-header h3 {
|
margin: 0;
|
}
|
|
.panel-header p {
|
margin: 4px 0 0;
|
color: #909399;
|
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;
|
}
|
|
.step-desc {
|
font-size: 13px;
|
color: #606266;
|
}
|
</style>
|