Files
xgy-oa/klp-ui/src/views/wms/receive/components/ImportGuide.vue
砂糖 306e153239 feat(收货管理): 实现钢卷Excel导入功能并优化UI交互
添加完整的钢卷Excel导入向导功能,包括文件选择、数据校验、预览和批量导入
增加导入进度展示和错误提示功能
调整导入弹窗宽度为80%以提供更好的操作空间
为浮动层添加过渡动画效果提升用户体验
2025-12-01 17:33:20 +08:00

609 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="import-wizard-container">
<!-- 导入模式选择 -->
<div class="import-mode-selector">
<el-radio-group v-model="importMode" @change="reset">
<el-radio label="validate">校验导入推荐</el-radio>
<el-radio label="direct">直接导入不校验</el-radio>
</el-radio-group>
</div>
<!-- 文件上传区域 -->
<div class="file-upload-area" :class="{ disabled: importStatus === 'processing' }">
<el-upload
ref="upload"
class="upload-excel"
action=""
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileChange"
accept=".xlsx,.xls"
:disabled="importStatus === 'processing'"
>
<el-button type="primary" icon="el-icon-upload2">选择Excel文件</el-button>
</el-upload>
<!-- 操作按钮 -->
<el-button
type="success"
icon="el-icon-check"
@click="handleValidate"
v-if="file && importMode === 'validate' && importStatus === 'idle'"
:disabled="!file"
>
校验数据
</el-button>
<el-button
type="warning"
icon="el-icon-circle-check"
@click="startImport"
v-if="file && (importMode === 'direct' || (importMode === 'validate' && errorList.length === 0)) && importStatus === 'idle'"
:disabled="!file || (importMode === 'validate' && errorList.length > 0)"
>
开始导入
</el-button>
<el-button
type="default"
icon="el-icon-refresh"
@click="reset"
:disabled="importStatus === 'processing'"
>
重置
</el-button>
</div>
<!-- 校验错误提示 -->
<div v-if="errorList.length > 0" class="error-list">
<el-alert
title="数据校验失败"
type="error"
description="以下行数据不符合格式要求,请修正后重新导入:"
show-icon
/>
<el-table :data="errorList" border size="small" max-height="200">
<el-table-column prop="rowNum" label="行号" width="80" />
<el-table-column prop="errorMsg" label="错误信息" />
</el-table>
</div>
<!-- 数据预览 -->
<div v-if="tableData.length > 0 && importStatus === 'idle'" class="data-preview">
<el-alert
title="数据预览"
type="info"
:description="`共解析出 ${tableData.length} 条有效数据`"
show-icon
:closable="false"
/>
<el-table :data="tableData" border size="small" max-height="300" stripe>
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="logicWarehouse" label="逻辑库区" width="120" />
<el-table-column prop="inboundCoilNo" label="入场卷号" width="150" />
<el-table-column prop="factoryCoilNo" label="厂家卷号" width="150" />
<el-table-column prop="weight" label="重量(吨)" width="120" />
<el-table-column prop="remark" label="备注" min-width="100" />
<el-table-column prop="name" label="名称" min-width="100" />
<el-table-column prop="specification" label="规格" min-width="100" />
<el-table-column prop="material" label="材质" width="100" />
<el-table-column prop="manufacturer" label="厂家" min-width="100" />
<el-table-column prop="surfaceTreatmentDesc" label="表面处理" width="120" />
<el-table-column prop="zincLayer" label="锌层" width="100" />
</el-table>
</div>
<!-- 导入进度展示 -->
<div v-if="importStatus === 'processing'" class="import-progress">
<el-alert
title="正在导入数据"
type="warning"
:description="`当前进度:${progress}%`"
show-icon
:closable="false"
/>
<el-progress :percentage="progress" status="success" />
<p class="progress-tip">已导入 {{ importedCount }} / {{ totalCount }} 条数据</p>
</div>
<!-- 导入完成提示 -->
<div v-if="importStatus === 'finished'" class="import-finished">
<el-alert
title="导入完成"
type="success"
:description="`共成功导入 ${importedCount} 条数据,总计 ${totalCount} 条`"
show-icon
/>
</div>
<!-- 导入失败提示 -->
<div v-if="importStatus === 'error'" class="import-error">
<el-alert
title="导入失败"
type="error"
:description="importErrorMsg"
show-icon
/>
</div>
</div>
</template>
<script>
import * as XLSX from 'xlsx';
import { addMaterialCoil } from '@/api/wms/coil';
import { addPendingAction } from '@/api/wms/pendingAction';
import { listRawMaterial } from '@/api/wms/rawMaterial';
import { listProduct } from '@/api/wms/product';
export default {
name: 'MaterialCoilImportWizard',
data() {
return {
// 导入模式validate-校验导入 direct-直接导入
importMode: 'validate',
// 文件对象
file: null,
// 解析后的原始数据
rawData: [],
// 格式化后用于展示/导入的数据
tableData: [],
// 校验错误列表
errorList: [],
// 导入进度0-100
progress: 0,
// 已导入条数
importedCount: 0,
// 总数据条数
totalCount: 0,
// 导入状态idle-闲置 processing-处理中 finished-完成 error-失败
importStatus: 'idle',
// 导入错误信息
importErrorMsg: '',
// 规定的表头(顺序和名称必须匹配)
requiredHeaders: [
'类型', '逻辑库区', '入场卷号', '厂家卷号', '重量(吨)', '备注',
'名称', '规格', '材质', '厂家', '表面处理', '锌层'
],
// 表头映射(中文表头 -> 字段名)
headerMap: {
'类型': 'type',
'逻辑库区': 'logicWarehouse',
'入场卷号': 'inboundCoilNo',
'厂家卷号': 'factoryCoilNo',
'重量(吨)': 'weight',
'备注': 'remark',
'名称': 'name',
'规格': 'specification',
'材质': 'material',
'厂家': 'manufacturer',
'表面处理': 'surfaceTreatmentDesc',
'锌层': 'zincLayer'
}
};
},
methods: {
/**
* 处理文件选择
*/
handleFileChange(file) {
this.file = file.raw;
// 选择文件后自动读取Excel
this.readExcel();
},
/**
* 读取Excel文件内容
*/
async readExcel() {
if (!this.file) return;
try {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(this.file);
fileReader.onload = async (e) => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
// 取第一个sheet
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// 解析为JSON跳过表头从第二行开始
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
// 校验表头
this.validateHeaders(jsonData[0]);
// 解析数据行(从第二行开始)
this.rawData = jsonData.slice(1).filter(row => row.length > 0); // 过滤空行
// 格式化数据
this.formatExcel();
this.$message.success(`成功解析Excel共读取到 ${this.rawData.length} 条数据`);
} catch (error) {
this.$message.error(`解析Excel失败${error.message}`);
this.importStatus = 'error';
this.importErrorMsg = `解析Excel失败${error.message}`;
}
};
} catch (error) {
this.$message.error(`读取文件失败:${error.message}`);
this.importStatus = 'error';
this.importErrorMsg = `读取文件失败:${error.message}`;
}
},
/**
* 校验表头是否匹配
*/
validateHeaders(headers) {
this.errorList = [];
// 校验表头数量和名称
if (headers.length !== this.requiredHeaders.length) {
this.errorList.push({
rowNum: 1,
errorMsg: `表头数量不匹配,要求${this.requiredHeaders.length}列,实际${headers.length}`
});
return;
}
// 校验每列表头名称
headers.forEach((header, index) => {
if (header !== this.requiredHeaders[index]) {
this.errorList.push({
rowNum: 1,
errorMsg: `${index + 1}列表头错误,要求:"${this.requiredHeaders[index]}",实际:"${header}"`
});
}
});
if (this.errorList.length > 0) {
this.$message.error('Excel表头格式不符合要求请检查');
}
},
/**
* 校验Excel数据格式
*/
async validateExcel() {
if (this.rawData.length === 0) {
this.$message.warning('暂无数据可校验');
return;
}
this.errorList = [];
// 逐行校验
for (let i = 0; i < this.rawData.length; i++) {
const row = this.rawData[i];
const rowNum = i + 2; // 数据行从第二行开始,行号=索引+2
const rowObj = this.formatRowData(row, rowNum);
// 1. 校验必填项
const requiredFields = [
{ key: 'type', label: '类型', value: rowObj.type },
{ key: 'logicWarehouse', label: '逻辑库区', value: rowObj.logicWarehouse },
{ key: 'inboundCoilNo', label: '入场卷号', value: rowObj.inboundCoilNo },
{ key: 'name', label: '名称', value: rowObj.name },
{ key: 'specification', label: '规格', value: rowObj.specification },
];
// 检查必填项为空
for (const field of requiredFields) {
if (!field.value || field.value.toString().trim() === '') {
this.errorList.push({
rowNum,
errorMsg: `${field.label}不能为空`
});
}
}
// 2. 校验类型只能是“原料”或“成品”
if (rowObj.type && !['原料', '成品'].includes(rowObj.type.trim())) {
this.errorList.push({
rowNum,
errorMsg: '类型只能是"原料"或"成品"'
});
}
// 3. 校验重量是数字且大于0
if (rowObj.weight) {
const weight = Number(rowObj.weight);
if (isNaN(weight) || weight <= 0) {
this.errorList.push({
rowNum,
errorMsg: '重量必须是大于0的数字'
});
}
} else {
this.errorList.push({
rowNum,
errorMsg: '重量不能为空'
});
}
// 4. 预校验itemId是否存在仅校验模式
if (rowObj.type && ['原料', '成品'].includes(rowObj.type.trim()) && !this.errorList.some(err => err.rowNum === rowNum)) {
const itemId = await this._findItemId(rowObj);
if (!itemId) {
this.errorList.push({
rowNum,
errorMsg: `未找到唯一匹配的${rowObj.type}(名称:${rowObj.name},规格:${rowObj.specification}`
});
}
}
}
if (this.errorList.length > 0) {
this.$message.error(`数据校验失败,共发现${this.errorList.length}条错误`);
} else {
this.$message.success('数据校验通过,可以开始导入');
}
},
/**
* 格式化Excel行数据
*/
formatExcel() {
this.tableData = [];
if (this.rawData.length === 0) return;
// 逐行格式化
this.rawData.forEach((row, index) => {
const rowNum = index + 2;
const rowObj = this.formatRowData(row, rowNum);
this.tableData.push(rowObj);
});
},
/**
* 格式化单行数据
*/
formatRowData(row, rowNum) {
const rowObj = {};
// 映射表头和字段
this.requiredHeaders.forEach((header, index) => {
const field = this.headerMap[header];
// 处理空值,统一转为字符串
rowObj[field] = row[index] ? row[index].toString().trim() : '';
});
// 重量转数字
rowObj.weight = rowObj.weight ? Number(rowObj.weight) : 0;
// 增加行号
rowObj.rowNum = rowNum;
return rowObj;
},
/**
* 处理校验按钮点击
*/
async handleValidate() {
if (!this.file) {
this.$message.warning('请先选择Excel文件');
return;
}
await this.validateExcel();
},
/**
* 开始导入数据
*/
async startImport() {
if (!this.file || this.tableData.length === 0) {
this.$message.warning('暂无数据可导入');
return;
}
// 直接导入模式下先快速校验基础格式
if (this.importMode === 'direct') {
await this.validateExcel();
if (this.errorList.length > 0) {
this.$message.error('直接导入模式下基础格式校验失败,请修正');
return;
}
}
// 初始化导入状态
this.importStatus = 'processing';
this.progress = 0;
this.importedCount = 0;
this.totalCount = this.tableData.length;
this.importErrorMsg = '';
try {
await this.batchImport();
this.importStatus = 'finished';
this.$message.success(`导入完成!共成功导入${this.importedCount}条数据`);
} catch (error) {
this.importStatus = 'error';
this.importErrorMsg = `导入失败:${error.message},已导入${this.importedCount}条数据`;
this.$message.error(this.importErrorMsg);
}
},
/**
* 批量导入数据
*/
async batchImport() {
// 遍历所有数据行,逐个导入
for (let i = 0; i < this.tableData.length; i++) {
if (this.importStatus === 'error') break; // 发生错误则停止
const row = this.tableData[i];
try {
await this.importOneRecord(row);
this.importedCount++;
// 更新进度
this.progress = Math.round(((i + 1) / this.totalCount) * 100);
} catch (error) {
// 单条失败可选择继续或终止,这里选择终止
throw new Error(`${row.rowNum}行导入失败:${error.message}`);
}
}
},
/**
* 导入单条记录
*/
async importOneRecord(row) {
try {
// 1. 查找itemId
const itemId = await this._findItemId(row);
if (!itemId) {
throw new Error(`未找到唯一的${row.type}信息`);
}
const itemType = row.type === '原料' ? 'raw_material' : 'product';
// 2. 插入钢卷数据dataType=10
const coilParams = {
itemId,
itemType,
logicWarehouse: row.logicWarehouse,
inboundCoilNo: row.inboundCoilNo,
factoryCoilNo: row.factoryCoilNo,
weight: row.weight,
remark: row.remark,
dataType: 10 // 钢卷数据类型固定为10
};
const coilRes = await addMaterialCoil(coilParams);
if (!coilRes.success) { // 假设接口返回success标识
throw new Error(`钢卷数据插入失败:${coilRes.message || '接口返回异常'}`);
}
const coilId = coilRes.data?.coilId; // 假设返回钢卷ID
// 3. 插入待处理操作actionStatus=0
const actionParams = {
coilId,
actionStatus: 0, // 待处理状态固定为0
itemType,
itemId,
operationType: 'import', // 操作类型:导入
remark: `Excel导入${row.inboundCoilNo}`
};
const actionRes = await addPendingAction(actionParams);
if (!actionRes.success) {
throw new Error(`待处理操作插入失败:${actionRes.message || '接口返回异常'}`);
}
} catch (error) {
throw new Error(error.message);
}
},
/**
* 根据条件查找唯一的itemId
*/
async _findItemId(row) {
const itemType = row.type === '原料' ? 'raw_material' : 'product';
let res = null;
try {
if (itemType === 'raw_material') {
res = await listRawMaterial({
rawMaterialName: row.name,
specification: row.specification,
material: row.material,
manufacturer: row.manufacturer,
surfaceTreatmentDesc: row.surfaceTreatmentDesc,
zincLayer: row.zincLayer
});
} else {
res = await listProduct({
productName: row.name,
specification: row.specification,
material: row.material,
manufacturer: row.manufacturer,
surfaceTreatmentDesc: row.surfaceTreatmentDesc,
zincLayer: row.zincLayer
});
}
// 校验返回结果数量
// 如果锌层,表面处理,厂家未传递,则需要从结果中取出这些值未空的记录
const record = res.data.filter(item =>
(!row.zincLayer || item.zincLayer === row.zincLayer) &&
(!row.surfaceTreatmentDesc || item.surfaceTreatmentDesc === row.surfaceTreatmentDesc) &&
(!row.manufacturer || item.manufacturer === row.manufacturer)
);
if (record.length !== 1) {
return null;
}
// 返回对应ID
return itemType === 'raw_material'
? res.data[0].rawMaterialId
: res.data[0].productId;
} catch (error) {
this.$message.error(`查询${row.type}信息失败:${error.message}`);
return null;
}
},
/**
* 重置所有状态
*/
reset() {
this.file = null;
this.rawData = [];
this.tableData = [];
this.errorList = [];
this.progress = 0;
this.importedCount = 0;
this.totalCount = 0;
this.importStatus = 'idle';
this.importErrorMsg = '';
this.$refs.upload?.clearFiles(); // 清空上传组件文件
}
}
};
</script>
<style scoped>
.import-wizard-container {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.import-mode-selector {
margin-bottom: 20px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.file-upload-area {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.file-upload-area.disabled {
opacity: 0.6;
pointer-events: none;
}
.error-list {
margin-bottom: 20px;
}
.data-preview {
margin-bottom: 20px;
}
.import-progress {
margin-bottom: 20px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.progress-tip {
margin: 10px 0 0 0;
color: #666;
font-size: 14px;
}
.import-finished, .import-error {
margin-bottom: 20px;
}
</style>