| | |
| | | <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> |
| | | <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" |
| | |
| | | > |
| | | <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> |
| | | </div> |
| | | |
| | | <el-form :model="form" label-width="120px"> |
| | | <el-form-item label="玻璃ID列表"> |
| | | <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="请输入玻璃条码,支持多行或逗号分隔" |
| | | placeholder="可选:输入玻璃ID,将使用输入的ID进行测试" |
| | | show-word-limit |
| | | :maxlength="5000" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="位置编码"> |
| | | <el-input v-model="form.positionCode" placeholder="例如:POS1" /> |
| | | </el-form-item> |
| | | <el-form-item label="存储位置"> |
| | | <el-input-number v-model="form.storagePosition" :min="1" :max="200" /> |
| | | </el-form-item> |
| | | <el-form-item label="执行间隔 (ms)"> |
| | | <el-input-number v-model="form.executionInterval" :min="100" :max="10000" /> |
| | | <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 } 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: { |
| | |
| | | }) |
| | | |
| | | const emit = defineEmits(['task-started']) |
| | | //配置默认值 |
| | | const form = reactive({}) |
| | | |
| | | const form = reactive({ |
| | | positionCode: '', |
| | | storagePosition: null, |
| | | executionInterval: 1000 |
| | | }) |
| | | 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, |
| | |
| | | .map((item) => item.trim()) |
| | | .filter((item) => item.length > 0) |
| | | }) |
| | | |
| | | const normalizeType = (type) => (type || '').trim().toUpperCase() |
| | | |
| | | const fetchLoadDevice = async () => { |
| | | loadDeviceId.value = null |
| | |
| | | const deviceList = Array.isArray(rawList) |
| | | ? rawList |
| | | : Array.isArray(rawList?.records) |
| | | ? rawList.records |
| | | : Array.isArray(rawList?.data) |
| | | ? rawList.data |
| | | : [] |
| | | const targetDevice = |
| | | deviceList.find((item) => (item.deviceType || '').toUpperCase() === 'LOAD_VEHICLE') || |
| | | deviceList[0] |
| | | ? 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}` |
| | |
| | | ElMessage.warning('请先选择设备组') |
| | | return |
| | | } |
| | | if (glassIds.value.length === 0) { |
| | | ElMessage.warning('请至少输入一个玻璃ID') |
| | | |
| | | // 表单验证 |
| | | if (!formRef.value) return |
| | | try { |
| | | await formRef.value.validate() |
| | | } catch (error) { |
| | | ElMessage.warning('请检查表单输入') |
| | | return |
| | | } |
| | | |
| | | try { |
| | | loading.value = true |
| | | await multiDeviceTaskApi.startTask({ |
| | | |
| | | // 构建任务参数 |
| | | // 如果输入了玻璃ID,使用输入的;如果没有输入,glassIds为空数组,后端会从数据库读取 |
| | | const parameters = { |
| | | glassIds: glassIds.value.length > 0 ? glassIds.value : [] |
| | | } |
| | | |
| | | // 异步启动任务,立即返回,不阻塞 |
| | | const response = await multiDeviceTaskApi.startTask({ |
| | | groupId: props.group.id || props.group.groupId, |
| | | parameters: { |
| | | glassIds: glassIds.value, |
| | | positionCode: form.positionCode || null, |
| | | storagePosition: form.storagePosition, |
| | | executionInterval: form.executionInterval |
| | | } |
| | | parameters |
| | | }) |
| | | ElMessage.success('任务已启动') |
| | | emit('task-started') |
| | | |
| | | 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 { |
| | |
| | | return |
| | | } |
| | | if (!loadDeviceId.value) { |
| | | ElMessage.warning('未找到上大车设备,无法清空PLC') |
| | | ElMessage.warning('未找到对应设备,无法清空PLC') |
| | | return |
| | | } |
| | | try { |
| | | clearLoading.value = true |
| | | const response = await deviceInteractionApi.executeOperation({ |
| | | deviceId: loadDeviceId.value, |
| | | operation: 'clearGlass', |
| | | params: { |
| | | positionCode: form.positionCode || null |
| | | } |
| | | operation: 'clearPlc', |
| | | params: {} |
| | | }) |
| | | if (response?.code !== 200) { |
| | | throw new Error(response?.message || 'PLC清空失败') |
| | |
| | | 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 |
| | | } |
| | | |
| | | // 发送数据到 MES 接口 |
| | | 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('order') || |
| | | headerStr.includes('orderno') || headerStr === '订单号') { |
| | | headerMap.orderNumber = 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 orderNumber = parseInt(row[headerMap.orderNumber]) || '' |
| | | 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 |
| | | orderNumber: orderNumber, |
| | | 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) |
| | | |
| | | if (response?.code === 200 || response?.code === 0 || response?.data) { |
| | | ElMessage.success(`成功导入 ${glassDataList.length} 条玻璃数据,工程号:${requestData.engineerId}`) |
| | | |
| | | // 将导入的玻璃ID填充到输入框,方便用户查看和编辑 |
| | | const glassIds = glassDataList.map(item => item.glassId).filter(id => id) |
| | | if (glassIds.length > 0) { |
| | | glassIdsInput.value = glassIds.join('\n') |
| | | } |
| | | } else { |
| | | throw new Error(response?.message || '导入失败') |
| | | } |
| | | } catch (error) { |
| | | console.error('提交玻璃数据失败:', error) |
| | | |
| | | // 显示错误信息 |
| | | const errorMsg = error?.response?.data?.message || 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> |
| | |
| | | gap: 12px; |
| | | align-items: center; |
| | | } |
| | | </style> |
| | | |
| | | .form-tip { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .topology-section { |
| | | margin-top: 24px; |
| | | } |
| | | </style> |