huang
2025-12-02 628aa6a42e587e9f337e213f87f922fc2ab2af02
mes-web/src/views/plcTest/components/MultiDeviceTest/ExecutionMonitor.vue
@@ -4,11 +4,34 @@
      <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
@@ -17,35 +40,96 @@
      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>
@@ -55,14 +139,25 @@
</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
  }
})
@@ -73,6 +168,13 @@
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 {
@@ -90,8 +192,223 @@
  }
}
// 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 {
@@ -104,6 +421,20 @@
  }
}
// 根据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':
@@ -112,9 +443,67 @@
      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) => {
@@ -123,18 +512,72 @@
  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>
@@ -163,6 +606,56 @@
  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;