mes-web/src/views/plcTest/components/MultiDeviceTest/TaskOrchestration.vue
@@ -8,7 +8,7 @@
        <p v-if="group && loadDeviceName" class="sub-info">当前设备:{{ loadDeviceName }}</p>
      </div>
      <div class="action-buttons">
        <el-button
        <!-- <el-button
          type="danger"
          plain
          :disabled="!group || !loadDeviceId || loadDeviceLoading"
@@ -17,9 +17,18 @@
        >
          <el-icon><Delete /></el-icon>
          清空PLC
        </el-button> -->
        <el-button type="success" :disabled="!group" :loading="importLoading" @click="handleImportExcel">
          <el-icon>
            <Upload />
          </el-icon>
          导入Excel数据
        </el-button>
        <input ref="fileInputRef" type="file" accept=".xlsx,.xls" style="display: none" @change="handleFileChange" />
        <el-button type="primary" :disabled="!group" :loading="loading" @click="handleSubmit">
          <el-icon><Promotion /></el-icon>
          <el-icon>
            <Promotion />
          </el-icon>
          启动测试
        </el-button>
      </div>
@@ -31,7 +40,7 @@
          v-model="glassIdsInput"
          type="textarea"
          :rows="4"
          placeholder="可选:如果输入玻璃ID,将使用输入的ID进行测试(代替卧转立扫码);如果不输入,将从数据库读取最近扫码的玻璃ID进行测试"
          placeholder="可选:输入玻璃ID,将使用输入的ID进行测试"
          show-word-limit
          :maxlength="5000"
        />
@@ -40,63 +49,22 @@
          <span v-else>未输入玻璃ID(正常模式:将从数据库读取最近扫码的玻璃ID)</span>
        </div>
      </el-form-item>
      <el-divider content-position="left">执行配置</el-divider>
      <el-form-item label="单片间隔 (秒)">
        <el-input-number
          v-model="form.glassIntervalSeconds"
          :min="0"
          :max="60"
          :step="0.1"
          :precision="1"
          placeholder="每个玻璃ID之间的间隔时间"
        />
        <div class="form-tip">多个玻璃ID时,每个玻璃ID传递之间的间隔时间(秒),用于模拟玻璃每片运动的时间。0表示一次性全部传递</div>
      </el-form-item>
      <el-form-item label="执行间隔 (ms)">
        <el-input-number
          v-model="form.executionInterval"
          :min="100"
          :max="10000"
          :step="100"
          placeholder="设备操作间隔时间"
        />
        <div class="form-tip">每个设备操作之间的间隔时间(毫秒)</div>
      </el-form-item>
      <el-form-item label="超时时间 (分钟)">
        <el-input-number
          v-model="form.timeoutMinutes"
          :min="1"
          :max="60"
          :step="1"
          placeholder="任务超时时间"
        />
        <div class="form-tip">任务执行的最大超时时间</div>
      </el-form-item>
      <el-form-item label="重试次数">
        <el-input-number
          v-model="form.retryCount"
          :min="0"
          :max="10"
          :step="1"
          placeholder="失败重试次数"
        />
        <div class="form-tip">设备操作失败时的最大重试次数</div>
      </el-form-item>
    </el-form>
    <!-- 设备组拓扑图 -->
    <GroupTopology v-if="group" :group="group" class="topology-section" />
  </div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete, Promotion } from '@element-plus/icons-vue'
import { Delete, Promotion, Upload } from '@element-plus/icons-vue'
import * as XLSX from 'xlsx'
import { multiDeviceTaskApi } from '@/api/device/multiDeviceTask'
import { deviceGroupApi, deviceInteractionApi } from '@/api/device/deviceManagement'
import { engineeringApi } from '@/api/engineering'
import GroupTopology from '../DeviceGroup/GroupTopology.vue'
const props = defineProps({
  group: {
@@ -107,12 +75,7 @@
const emit = defineEmits(['task-started'])
//配置默认值
const form = reactive({
  glassIntervalSeconds: 10, // 单片间隔,默认10秒
  executionInterval: 1000,
  timeoutMinutes: 1,
  retryCount: 3
})
const form = reactive({})
const formRef = ref(null)
@@ -146,10 +109,12 @@
const glassIdsInput = ref('')
const loading = ref(false)
const importLoading = ref(false)
const clearLoading = ref(false)
const loadDeviceId = ref(null)
const loadDeviceName = ref('')
const loadDeviceLoading = ref(false)
const fileInputRef = ref(null)
watch(
  () => props.group,
@@ -186,10 +151,10 @@
    const deviceList = Array.isArray(rawList)
      ? rawList
      : Array.isArray(rawList?.records)
      ? rawList.records
      : Array.isArray(rawList?.data)
      ? rawList.data
      : []
        ? rawList.records
        : Array.isArray(rawList?.data)
          ? rawList.data
          : []
    const scannerDevice = deviceList.find((item) => {
      const type = normalizeType(item.deviceType)
      return type.includes('SCANNER') || type.includes('扫码')
@@ -216,7 +181,7 @@
    ElMessage.warning('请先选择设备组')
    return
  }
  // 表单验证
  if (!formRef.value) return
  try {
@@ -225,49 +190,35 @@
    ElMessage.warning('请检查表单输入')
    return
  }
  try {
    loading.value = true
    // 构建任务参数
    // 如果输入了玻璃ID,使用输入的;如果没有输入,glassIds为空数组,后端会从数据库读取
    // 将秒转换为毫秒传给后端
    const glassIntervalMs = form.glassIntervalSeconds != null && form.glassIntervalSeconds !== undefined
      ? Math.round(form.glassIntervalSeconds * 1000)
      : 1000
    const parameters = {
      glassIds: glassIds.value.length > 0 ? glassIds.value : [],
      glassIntervalMs: glassIntervalMs,
      executionInterval: form.executionInterval || 1000
      glassIds: glassIds.value.length > 0 ? glassIds.value : []
    }
    // 设备特定配置已移除,如有需要可在此扩展
    if (form.timeoutMinutes) {
      parameters.timeoutMinutes = form.timeoutMinutes
    }
    if (form.retryCount !== null) {
      parameters.retryCount = form.retryCount
    }
    // 异步启动任务,立即返回,不阻塞
    const response = await multiDeviceTaskApi.startTask({
      groupId: props.group.id || props.group.groupId,
      parameters
    })
    const task = response?.data
    if (task && task.taskId) {
      ElMessage.success(`任务已启动(异步执行): ${task.taskId}`)
      emit('task-started', task)
      // 立即刷新监控列表,显示新启动的任务
      setTimeout(() => {
        emit('task-started')
      }, 500)
      // 重置表单(保留执行配置),方便继续启动其他设备组
      glassIdsInput.value = ''
      // 提示用户可以继续启动其他设备组
      ElMessage.info('可以继续选择其他设备组启动测试,多个设备组将并行执行')
    } else {
@@ -311,6 +262,292 @@
    ElMessage.error(error?.message || 'PLC清空失败')
  } finally {
    clearLoading.value = false
  }
}
// 处理导入Excel按钮点击
const handleImportExcel = () => {
  if (!props.group) {
    ElMessage.warning('请先选择设备组')
    return
  }
  if (fileInputRef.value) {
    fileInputRef.value.click()
  }
}
// 处理文件选择
const handleFileChange = async (event) => {
  const file = event.target.files?.[0]
  if (!file) {
    return
  }
  // 验证文件类型
  const fileName = file.name.toLowerCase()
  if (!fileName.endsWith('.xlsx') && !fileName.endsWith('.xls')) {
    ElMessage.error('请选择 Excel 文件(.xlsx 或 .xls)')
    event.target.value = ''
    return
  }
  try {
    importLoading.value = true
    // 读取文件
    const fileReader = new FileReader()
    fileReader.onload = (e) => {
      try {
        const data = new Uint8Array(e.target.result)
        const workbook = XLSX.read(data, { type: 'array' })
        // 读取第一个工作表
        const firstSheetName = workbook.SheetNames[0]
        const worksheet = workbook.Sheets[firstSheetName]
        // 转换为 JSON 数组
        const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 })
        if (!jsonData || jsonData.length === 0) {
          ElMessage.error('Excel 文件为空')
          event.target.value = ''
          return
        }
        // 解析数据(假设第一行是表头)
        const parsedData = parseExcelData(jsonData)
        if (parsedData.length === 0) {
          ElMessage.error('未能解析到有效数据,请检查 Excel 格式')
          event.target.value = ''
          return
        }
        // 发送数据
        submitGlassData(parsedData)
      } catch (error) {
        console.error('解析 Excel 失败:', error)
        ElMessage.error('解析 Excel 文件失败: ' + (error.message || '未知错误'))
      } finally {
        event.target.value = ''
        importLoading.value = false
      }
    }
    fileReader.onerror = () => {
      ElMessage.error('读取文件失败')
      event.target.value = ''
      importLoading.value = false
    }
    fileReader.readAsArrayBuffer(file)
  } catch (error) {
    console.error('导入 Excel 失败:', error)
    ElMessage.error('导入 Excel 失败: ' + (error.message || '未知错误'))
    importLoading.value = false
    event.target.value = ''
  }
}
// 解析 Excel 数据
const parseExcelData = (jsonData) => {
  if (!jsonData || jsonData.length < 2) {
    return []
  }
  // 尝试识别表头(支持中英文)
  const headerRow = jsonData[0]
  const headerMap = {}
  headerRow.forEach((header, index) => {
    if (!header) return
    const headerStr = String(header).trim().toLowerCase()
    // 玻璃ID
    if (headerStr.includes('玻璃id') || headerStr.includes('glassid') ||
      (headerStr.includes('玻璃') && headerStr.includes('id')) ||
      headerStr === 'id' || headerStr === 'glass_id') {
      headerMap.glassId = index
    }
    // 宽度
    else if (headerStr.includes('宽') || headerStr.includes('width') ||
      headerStr === 'w' || headerStr === '宽度') {
      headerMap.width = index
    }
    // 高度
    else if (headerStr.includes('高') || headerStr.includes('height') ||
      headerStr === 'h' || headerStr === '高度') {
      headerMap.height = index
    }
    // 厚度
    else if (headerStr.includes('厚') || headerStr.includes('thickness') ||
      headerStr === 't' || headerStr === '厚度') {
      headerMap.thickness = index
    }
    // 数量
    else if (headerStr.includes('数量') || headerStr.includes('quantity') ||
      headerStr.includes('qty') || headerStr === '数量') {
      headerMap.quantity = index
    }
    // 膜系
    else if (headerStr.includes('膜系') || headerStr.includes('films') ||
      headerStr.includes('film') || headerStr === '膜系id') {
      headerMap.filmsId = index
    }
    // 流程卡ID
    else if (headerStr.includes('流程卡') || headerStr.includes('flowcard') ||
      headerStr.includes('flow') || headerStr === '流程卡id') {
      headerMap.flowCardId = index
    }
    // 产品名称
    else if (headerStr.includes('产品') || headerStr.includes('product') ||
      headerStr === '产品名称') {
      headerMap.productName = index
    }
    // 客户名称
    else if (headerStr.includes('客户') || headerStr.includes('customer') ||
      headerStr === '客户名称') {
      headerMap.customerName = index
    }
  })
  // 如果没有找到表头,尝试使用第一行作为表头(索引方式)
  if (Object.keys(headerMap).length === 0 && jsonData.length > 1) {
    // 默认格式:玻璃ID, 宽, 高, 厚, 数量(按列顺序)
    headerMap.glassId = 0
    headerMap.width = 1
    headerMap.height = 2
    headerMap.thickness = 3
    headerMap.quantity = 4
  }
  // 解析数据行
  const result = []
  for (let i = 1; i < jsonData.length; i++) {
    const row = jsonData[i]
    if (!row || row.length === 0) continue
    const glassId = row[headerMap.glassId] ? String(row[headerMap.glassId]).trim() : ''
    const width = row[headerMap.width] ? String(row[headerMap.width]).trim() : ''
    const height = row[headerMap.height] ? String(row[headerMap.height]).trim() : ''
    const thickness = row[headerMap.thickness] ? String(row[headerMap.thickness]).trim() : ''
    const quantity = row[headerMap.quantity] ? String(row[headerMap.quantity]).trim() : ''
    const filmsId = row[headerMap.filmsId] ? String(row[headerMap.filmsId]).trim() : ''
    const flowCardId = row[headerMap.flowCardId] ? String(row[headerMap.flowCardId]).trim() : ''
    const productName = row[headerMap.productName] ? String(row[headerMap.productName]).trim() : ''
    const customerName = row[headerMap.customerName] ? String(row[headerMap.customerName]).trim() : ''
    // 跳过空行
    if (!glassId && !width && !height && !thickness && !quantity) {
      continue
    }
    // 验证必填字段
    if (!glassId) {
      ElMessage.warning(`第 ${i + 1} 行:玻璃ID为空,已跳过`)
      continue
    }
    // 转换数值类型,确保格式正确
    const parseNumber = (value) => {
      if (!value) return '0'
      const num = parseFloat(value)
      return isNaN(num) ? '0' : String(num)
    }
    // 处理数量:如果数量大于1,需要生成多条记录
    const qty = parseInt(quantity) || 1
    for (let j = 0; j < qty; j++) {
      // 如果数量大于1,为每条记录生成唯一的玻璃ID(追加序号)
      const finalGlassId = qty > 1 ? `${glassId}_${j + 1}` : glassId
      result.push({
        glassId: finalGlassId,
        width: parseNumber(width),
        height: parseNumber(height),
        thickness: parseNumber(thickness),
        quantity: '1', // 每条记录数量为1
        filmsId: filmsId,
        flowCardId: flowCardId || finalGlassId,
        productName: productName,
        customerName: customerName
      })
    }
  }
  return result
}
// 提交玻璃数据到后端,由后端完成 JSON 转换并调用 MES 接口
const submitGlassData = async (glassDataList) => {
  if (!props.group) {
    ElMessage.warning('请先选择设备组')
    return
  }
  try {
    importLoading.value = true
    // 传递原始解析数据给后端,后端完成转换与 MES 调用
    const requestData = { excelRows: glassDataList }
    // 打印原始数据供调试
    console.log('上传到后端的原始 Excel 数据:', JSON.stringify(requestData, null, 2))
    const response = await engineeringApi.importEngineer(requestData)
    // 检查 MES 接口是否调用成功
    // MES 接口成功时返回格式:{ code: 200/0, data: true/false, message: "..." }
    if (response?.code === 200 || response?.code === 0) {
      // MES 接口调用成功
      const engineerId = response?.data?.engineerId || response?.engineerId || ''
      const successMsg = engineerId
        ? `成功导入 ${glassDataList.length} 条玻璃数据,工程号:${engineerId}`
        : `成功导入 ${glassDataList.length} 条玻璃数据`
      ElMessage.success(successMsg)
      // 将导入的玻璃ID填充到输入框,方便用户查看和编辑
      const glassIds = glassDataList.map(item => item.glassId).filter(id => id)
      if (glassIds.length > 0) {
        glassIdsInput.value = glassIds.join('\n')
      }
    } else {
      // MES 接口返回失败
      throw new Error(response?.message || 'MES 接口返回失败')
    }
  } catch (error) {
    console.error('提交玻璃数据失败:', error)
    // 判断错误类型,给出更友好的提示
    let errorMsg = '未知错误'
    // 检查是否是后端返回的错误响应(后端转发 MES 失败)
    if (error?.response?.status === 500 && error?.response?.data) {
      // 后端返回的统一错误格式
      errorMsg = error.response.data.message || error.response.data || '转发 MES 接口失败'
    } else if (error?.response?.data?.message) {
      // MES 接口返回的错误
      errorMsg = error.response.data.message
    } else if (error?.message) {
      errorMsg = error.message
    }
    ElMessage.error('提交数据失败: ' + errorMsg)
    // 即使失败,也尝试填充玻璃ID到输入框
    try {
      const glassIds = glassDataList.map(item => item.glassId).filter(id => id)
      if (glassIds.length > 0) {
        glassIdsInput.value = glassIds.join('\n')
        ElMessage.info('已将玻璃ID填充到输入框,您可以手动提交')
      }
    } catch (e) {
      console.error('填充数据失败:', e)
    }
  } finally {
    importLoading.value = false
  }
}
</script>
@@ -362,5 +599,8 @@
  margin-top: 4px;
  line-height: 1.4;
}
</style>
.topology-section {
  margin-top: 24px;
}
</style>