<template>
|
<div class="task-orchestration">
|
<div class="panel-header">
|
<div>
|
<h3>多设备测试编排</h3>
|
<p v-if="group">当前设备组:{{ group.groupName }}({{ group.deviceCount || '-' }} 台设备)</p>
|
<p v-else class="warning">请先在左侧选择一个设备组</p>
|
<p v-if="group && loadDeviceName" class="sub-info">当前设备:{{ loadDeviceName }}</p>
|
</div>
|
<div class="action-buttons">
|
<!-- <el-button
|
type="danger"
|
plain
|
:disabled="!group || !loadDeviceId || loadDeviceLoading"
|
:loading="clearLoading"
|
@click="handleClearPlc"
|
>
|
<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-button>
|
</div>
|
</div>
|
|
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
|
<el-form-item label="玻璃ID列表" prop="glassIds">
|
<el-input
|
v-model="glassIdsInput"
|
type="textarea"
|
:rows="4"
|
placeholder="可选:输入玻璃ID,将使用输入的ID进行测试"
|
show-word-limit
|
:maxlength="5000"
|
/>
|
<div class="form-tip">
|
<span v-if="glassIds.length > 0">已输入 {{ glassIds.length }} 个玻璃ID(测试模式:使用输入的ID)</span>
|
<span v-else>未输入玻璃ID(正常模式:将从数据库读取最近扫码的玻璃ID)</span>
|
</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, 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: {
|
type: Object,
|
default: null
|
}
|
})
|
|
const emit = defineEmits(['task-started'])
|
//配置默认值
|
const form = reactive({})
|
|
const formRef = ref(null)
|
|
const rules = {
|
glassIds: [
|
{
|
validator: (rule, value, callback) => {
|
// 如果输入了玻璃ID,则进行验证;如果没有输入,则允许(将从数据库读取)
|
if (glassIds.value.length === 0) {
|
// 允许为空,将从数据库读取最近扫码的玻璃ID
|
callback()
|
} else if (glassIds.value.length > 100) {
|
callback(new Error('玻璃ID数量不能超过100个'))
|
} else {
|
// 验证玻璃ID格式
|
const invalidIds = glassIds.value.filter(id => {
|
// 简单的格式验证:不能为空,长度在1-50之间
|
return !id || id.length === 0 || id.length > 50
|
})
|
if (invalidIds.length > 0) {
|
callback(new Error(`存在无效的玻璃ID格式,请检查`))
|
} else {
|
callback()
|
}
|
}
|
},
|
trigger: 'blur'
|
}
|
]
|
}
|
|
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,
|
() => {
|
glassIdsInput.value = ''
|
fetchLoadDevice()
|
}
|
)
|
|
const glassIds = computed(() => {
|
if (!glassIdsInput.value) return []
|
return glassIdsInput.value
|
.split(/[\n,,]/)
|
.map((item) => item.trim())
|
.filter((item) => item.length > 0)
|
})
|
|
const normalizeType = (type) => (type || '').trim().toUpperCase()
|
|
const fetchLoadDevice = async () => {
|
loadDeviceId.value = null
|
loadDeviceName.value = ''
|
if (!props.group) {
|
return
|
}
|
const groupId = props.group.id || props.group.groupId
|
if (!groupId) {
|
return
|
}
|
loadDeviceLoading.value = true
|
try {
|
const response = await deviceGroupApi.getGroupDevices(groupId)
|
const rawList = response?.data
|
const deviceList = Array.isArray(rawList)
|
? rawList
|
: Array.isArray(rawList?.records)
|
? rawList.records
|
: Array.isArray(rawList?.data)
|
? rawList.data
|
: []
|
const scannerDevice = deviceList.find((item) => {
|
const type = normalizeType(item.deviceType)
|
return type.includes('SCANNER') || type.includes('扫码')
|
})
|
const loadVehicleDevice = deviceList.find((item) => {
|
const type = normalizeType(item.deviceType)
|
return type.includes('LOAD_VEHICLE') || type.includes('大车')
|
})
|
const targetDevice = scannerDevice || loadVehicleDevice || deviceList[0]
|
if (targetDevice && targetDevice.id) {
|
loadDeviceId.value = targetDevice.id
|
loadDeviceName.value = targetDevice.deviceName || targetDevice.deviceCode || `ID: ${targetDevice.id}`
|
}
|
} catch (error) {
|
console.error('加载设备信息失败:', error)
|
ElMessage.error(error?.message || '获取设备信息失败')
|
} finally {
|
loadDeviceLoading.value = false
|
}
|
}
|
|
const handleSubmit = async () => {
|
if (!props.group) {
|
ElMessage.warning('请先选择设备组')
|
return
|
}
|
|
// 表单验证
|
if (!formRef.value) return
|
try {
|
await formRef.value.validate()
|
} catch (error) {
|
ElMessage.warning('请检查表单输入')
|
return
|
}
|
|
try {
|
loading.value = true
|
|
// 构建任务参数
|
// 如果输入了玻璃ID,使用输入的;如果没有输入,glassIds为空数组,后端会从数据库读取
|
const parameters = {
|
glassIds: glassIds.value.length > 0 ? glassIds.value : []
|
}
|
|
// 异步启动任务,立即返回,不阻塞
|
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 {
|
ElMessage.warning('任务启动响应异常')
|
}
|
} catch (error) {
|
ElMessage.error(error?.message || '任务启动失败')
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const handleClearPlc = async () => {
|
if (!props.group) {
|
ElMessage.warning('请先选择设备组')
|
return
|
}
|
if (!loadDeviceId.value) {
|
ElMessage.warning('未找到对应设备,无法清空PLC')
|
return
|
}
|
try {
|
clearLoading.value = true
|
const response = await deviceInteractionApi.executeOperation({
|
deviceId: loadDeviceId.value,
|
operation: 'clearPlc',
|
params: {}
|
})
|
if (response?.code !== 200) {
|
throw new Error(response?.message || 'PLC清空失败')
|
}
|
const result = response?.data
|
if (result?.success) {
|
ElMessage.success(result?.message || 'PLC已清空')
|
glassIdsInput.value = ''
|
} else {
|
throw new Error(result?.message || 'PLC清空失败')
|
}
|
} catch (error) {
|
console.error('清空PLC失败:', error)
|
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>
|
|
<style scoped>
|
.task-orchestration {
|
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;
|
}
|
|
.panel-header .warning {
|
color: #f56c6c;
|
}
|
|
.panel-header .sub-info {
|
margin-top: 4px;
|
color: #606266;
|
font-size: 12px;
|
}
|
|
.action-buttons {
|
display: flex;
|
gap: 12px;
|
align-items: center;
|
}
|
|
.form-tip {
|
font-size: 12px;
|
color: #909399;
|
margin-top: 4px;
|
line-height: 1.4;
|
}
|
|
.topology-section {
|
margin-top: 24px;
|
}
|
</style>
|