Files
klp-oa/klp-ui/src/views/wms/receive/components/ImportGuide.vue
砂糖 51caea9e41 feat(wms): 优化收货功能并重构导入组件
重构收货功能界面,将"确认收货"改为"签收",并调整按钮样式。新增收货弹窗功能,支持选择实际库区和填写备注。

重构导入组件,优化校验逻辑和错误处理,增加防呆机制和loading状态。将枚举和常量提取为模块级变量,提升代码可维护性。添加仓库映射关系,增强数据校验能力。

调整表格按钮的内边距,提升UI一致性
2025-12-02 13:56:21 +08:00

773 lines
24 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="file-upload-area" :class="{ disabled: importStatus === 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 === ImportStatus.PROCESSING || validateLoading || importLoading"
>
<el-button type="primary" icon="el-icon-upload2">选择Excel文件</el-button>
</el-upload>
<!-- 操作按钮新增loading和防呆 -->
<el-button
type="success"
icon="el-icon-check"
@click="handleValidate"
v-if="file && importStatus === ImportStatus.IDLE"
:disabled="!file || validateLoading"
:loading="validateLoading"
>
校验数据
</el-button>
<el-button
type="warning"
icon="el-icon-circle-check"
@click="startImport"
v-if="file && importStatus === ImportStatus.IDLE"
:disabled="!file || !isValidated || errorList.length > 0 || importLoading"
:loading="importLoading"
>
开始导入
</el-button>
<el-button
type="default"
icon="el-icon-refresh"
@click="reset"
:disabled="importStatus === ImportStatus.PROCESSING || validateLoading || importLoading"
>
重置
</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 === 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
v-for="column in TableColumnEnum"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
/>
</el-table>
</div>
<!-- 导入进度展示 -->
<div v-if="importStatus === 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 === ImportStatus.FINISHED" class="import-finished">
<el-alert
title="导入完成"
type="success"
:description="`共成功导入 ${importedCount} 条数据,总计 ${totalCount} 条`"
show-icon
/>
</div>
<!-- 导入失败提示 -->
<div v-if="importStatus === 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';
import { listWarehouse } from '@/api/wms/warehouse';
// ===================== 枚举定义 =====================
/**
* 导入状态枚举
*/
const ImportStatus = Object.freeze({
IDLE: 'idle', // 闲置
PROCESSING: 'processing', // 处理中
FINISHED: 'finished', // 完成
ERROR: 'error' // 失败
});
/**
* 错误类型枚举(含校验逻辑)
*/
const ErrorType = Object.freeze({
// 表头错误
HEADER_COUNT_ERROR: {
message: (required, actual) => `表头数量不匹配,要求${required}列,实际${actual}`
},
HEADER_NAME_ERROR: {
message: (index, required, actual) => `${index + 1}列表头错误,要求:"${required}",实际:"${actual}"`
},
// 字段校验错误
FIELD_REQUIRED: {
message: (label) => `${label}不能为空`,
validator: (rowObj, rowNum, errorList) => {
const requiredFields = [
{ key: 'type', label: '类型' },
{ key: 'logicWarehouse', label: '逻辑库区' },
{ key: 'inboundCoilNo', label: '入场卷号' },
{ key: 'name', label: '名称' },
{ key: 'specification', label: '规格' },
{ key: 'weight', label: '重量(吨)' }
];
requiredFields.forEach(field => {
const value = rowObj[field.key];
if (!value || (typeof value === 'string' && value.trim() === '')) {
errorList.push({
rowNum,
errorMsg: ErrorType.FIELD_REQUIRED.message(field.label)
});
}
});
}
},
// 类型错误
TYPE_ERROR: {
message: '类型只能是"原料"或"成品"',
validator: (rowObj, rowNum, errorList) => {
if (rowObj.type && !['原料', '成品'].includes(rowObj.type.trim())) {
errorList.push({
rowNum,
errorMsg: ErrorType.TYPE_ERROR.message
});
}
}
},
// 重量错误
WEIGHT_ERROR: {
message: '重量必须是大于0的数字',
validator: (rowObj, rowNum, errorList) => {
if (rowObj.weight) {
const weight = Number(rowObj.weight);
if (isNaN(weight) || weight <= 0) {
errorList.push({
rowNum,
errorMsg: ErrorType.WEIGHT_ERROR.message
});
}
}
}
},
// 仓库名不规范或不存在
WAREHOUSE_ERROR: {
message: (warehouseName) => `仓库名"${warehouseName}"不规范或不存在`,
validator: (rowObj, rowNum, errorList, warehouseMap) => {
const warehouseId = warehouseMap[rowObj.logicWarehouse];
if (!warehouseId) {
errorList.push({
rowNum,
errorMsg: ErrorType.WAREHOUSE_ERROR.message(rowObj.logicWarehouse)
});
}
}
},
// 物料ID不存在
ITEM_ID_NOT_FOUND: {
message: (type, name, spec, material, surfaceTreatmentDesc, manufacturer, zincLayer) => `未找到唯一匹配的${type}类型(名称:${name},规格:${spec},材质:${material || '无'},表面处理:${surfaceTreatmentDesc || '无'},厂家:${manufacturer || '无'},锌层:${zincLayer || '无'}`
}
});
/**
* 表格列配置枚举
*/
const TableColumnEnum = Object.freeze([
{ prop: 'type', label: '类型', width: 80 },
{ prop: 'logicWarehouse', label: '逻辑库区', width: 120 },
{ prop: 'inboundCoilNo', label: '入场卷号', width: 150 },
{ prop: 'factoryCoilNo', label: '厂家卷号', width: 150 },
{ prop: 'weight', label: '重量(吨)', width: 120 },
{ prop: 'remark', label: '备注', minWidth: 100 },
{ prop: 'name', label: '名称', minWidth: 100 },
{ prop: 'specification', label: '规格', minWidth: 100 },
{ prop: 'material', label: '材质', width: 100 },
{ prop: 'manufacturer', label: '厂家', minWidth: 100 },
{ prop: 'surfaceTreatmentDesc', label: '表面处理', width: 120 },
{ prop: 'zincLayer', label: '锌层', width: 100 }
]);
/**
* 系统常量枚举
*/
const SystemConstant = Object.freeze({
// Excel表头配置
REQUIRED_HEADERS: TableColumnEnum.map(col => col.label),
HEADER_MAP: TableColumnEnum.reduce((map, col) => {
map[col.label] = col.prop;
return map;
}, {}),
// 接口常量
COIL_DATA_TYPE: 10, // 钢卷数据类型
PENDING_ACTION_STATUS: 0, // 待处理状态
OPERATION_TYPE_IMPORT: 'import', // 操作类型-导入
PENDING_ACTION_TYPE_RECEIVE: 401, // 待处理类型-入库
// 物料类型映射
ITEM_TYPE_MAP: {
'原料': 'raw_material',
'成品': 'product'
},
// 物料ID字段映射
ITEM_ID_FIELD: {
'raw_material': 'rawMaterialId',
'product': 'productId'
}
});
export default {
name: 'MaterialCoilImportWizard',
props: {
planId: {
type: String,
default: 0
}
},
data() {
return {
// 枚举挂载到实例,方便模板使用
ImportStatus,
TableColumnEnum,
// 文件对象
file: null,
// 解析后的原始数据
rawData: [],
// 格式化后用于展示/导入的数据
tableData: [],
// 校验错误列表(为空表示校验通过)
errorList: [],
// 是否点击过校验按钮(新增状态)
isValidated: false,
// 导入进度0-100
progress: 0,
// 已导入条数
importedCount: 0,
// 总数据条数
totalCount: 0,
// 导入状态
importStatus: ImportStatus.IDLE,
// 导入错误信息
importErrorMsg: '',
// 仓库名到仓库id的映射关系
warehouseMap: {},
// 防呆loading状态
validateLoading: false, // 校验按钮loading
importLoading: false // 导入按钮loading
};
},
methods: {
/**
* 处理文件选择(新增防呆:操作中禁止切换文件)
*/
handleFileChange(file) {
// 操作中禁止切换文件
if (this.validateLoading || this.importLoading || this.importStatus === ImportStatus.PROCESSING) {
this.$message.warning('当前有操作正在进行中,请完成后再选择新文件');
return;
}
this.file = file.raw;
this.isValidated = false; // 选择新文件后重置校验状态
this.readExcel();
},
/**
* 获取所有的仓库建立仓库名到仓库id的映射关系
*/
async getWarehouseMap() {
try {
const response = await listWarehouse({ pageNum: 1, pageSize: 1000 });
const map = {};
for (let item of response.data) {
map[item.warehouseName] = item.warehouseId;
}
console.log(map, response);
this.warehouseMap = map;
} catch (error) {
this.handleError(`获取仓库列表失败:${error.message}`);
}
},
/**
* 读取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' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
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.handleError(`解析Excel失败${error.message}`);
}
};
} catch (error) {
this.handleError(`读取文件失败:${error.message}`);
}
},
/**
* 校验表头是否匹配
*/
validateHeaders(headers) {
this.errorList = [];
const { REQUIRED_HEADERS } = SystemConstant;
// 校验表头数量
if (headers.length !== REQUIRED_HEADERS.length) {
this.errorList.push({
rowNum: 1,
errorMsg: ErrorType.HEADER_COUNT_ERROR.message(REQUIRED_HEADERS.length, headers.length)
});
}
// 校验表头名称
headers.forEach((header, index) => {
if (header !== REQUIRED_HEADERS[index]) {
this.errorList.push({
rowNum: 1,
errorMsg: ErrorType.HEADER_NAME_ERROR.message(index, REQUIRED_HEADERS[index], header)
});
}
});
if (this.errorList.length > 0) {
this.$message.error('Excel表头格式不符合要求请检查');
}
},
/**
* 校验Excel数据格式唯一校验入口添加防重复点击
*/
async handleValidate() {
// 防重复点击校验
if (this.validateLoading) {
this.$message.warning('正在校验数据,请稍候...');
return;
}
if (!this.file) {
this.$message.warning('请先选择Excel文件');
return;
}
// 标记为已点击过校验
this.isValidated = true;
if (this.rawData.length === 0) {
this.$message.warning('暂无数据可校验');
return;
}
this.validateLoading = true; // 开启校验loading
this.errorList = [];
try {
// 逐行校验
for (let i = 0; i < this.rawData.length; i++) {
const row = this.rawData[i];
const rowNum = i + 2; // 数据行从第二行开始
const rowObj = this.formatRowData(row, rowNum);
// 执行各类校验器
ErrorType.FIELD_REQUIRED.validator(rowObj, rowNum, this.errorList);
ErrorType.TYPE_ERROR.validator(rowObj, rowNum, this.errorList);
ErrorType.WEIGHT_ERROR.validator(rowObj, rowNum, this.errorList);
ErrorType.WAREHOUSE_ERROR.validator(rowObj, rowNum, this.errorList, this.warehouseMap);
// 校验物料ID是否存在仅当当前行无基础错误时
if (!this.errorList.some(err => err.rowNum === rowNum) && rowObj.type) {
const itemId = await this._findItemId(rowObj);
if (!itemId) {
this.errorList.push({
rowNum,
errorMsg: ErrorType.ITEM_ID_NOT_FOUND.message(rowObj.type, rowObj.name, rowObj.specification, rowObj.material, rowObj.surfaceTreatmentDesc, rowObj.manufacturer, rowObj.zincLayer)
});
}
}
}
if (this.errorList.length > 0) {
this.$message.error(`数据校验失败,共发现${this.errorList.length}条错误`);
} else {
this.$message.success('数据校验通过,可以开始导入');
}
} catch (error) {
this.handleError(`校验数据时发生错误:${error.message}`);
} finally {
this.validateLoading = false; // 关闭校验loading
}
},
/**
* 格式化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 { HEADER_MAP, REQUIRED_HEADERS } = SystemConstant;
const rowObj = {};
// 映射表头和字段
REQUIRED_HEADERS.forEach((header, index) => {
const field = HEADER_MAP[header];
rowObj[field] = row[index] ? row[index].toString().trim() : '';
});
// 重量转数字
rowObj.weight = rowObj.weight ? Number(rowObj.weight) : 0;
// 增加行号
rowObj.rowNum = rowNum;
return rowObj;
},
/**
* 开始导入数据(添加防重复点击和二次确认)
*/
async startImport() {
// 防重复点击导入
if (this.importLoading) {
this.$message.warning('正在导入数据,请勿重复操作...');
return;
}
if (!this.file || this.tableData.length === 0) {
this.$message.warning('暂无数据可导入');
return;
}
// 二次确认,防止误操作
const confirm = await this.$confirm(
'确认导入已校验通过的数据?导入过程中请勿刷新页面或关闭浏览器!',
'导入确认',
{
confirmButtonText: '确认导入',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true
}
).catch(() => false);
if (!confirm) return;
this.importLoading = true; // 开启导入loading
// 初始化导入状态
this.importStatus = ImportStatus.PROCESSING;
this.progress = 0;
this.importedCount = 0;
this.totalCount = this.tableData.length;
this.importErrorMsg = '';
try {
await this.batchImport();
this.importStatus = ImportStatus.FINISHED;
this.$message.success(`导入完成!共成功导入${this.importedCount}条数据`);
} catch (error) {
this.handleError(`导入失败:${error.message},已导入${this.importedCount}条数据`);
} finally {
this.importLoading = false; // 关闭导入loading
}
},
/**
* 批量导入数据
*/
async batchImport() {
for (let i = 0; i < this.tableData.length; i++) {
// 若导入已失败,终止后续操作
if (this.importStatus === ImportStatus.ERROR) break;
const row = this.tableData[i];
try {
await this.importOneRecord(row);
this.importedCount++;
// 更新进度(避免进度跳动过大)
const currentProgress = Math.round(((i + 1) / this.totalCount) * 100);
this.progress = currentProgress;
// 给浏览器渲染时间,避免卡死
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
throw new Error(`${row.rowNum}行导入失败:${error.message}`);
}
}
},
/**
* 导入单条记录
*/
async importOneRecord(row) {
try {
// 1. 查找物料ID再次校验防止数据篡改
const itemId = await this._findItemId(row);
if (!itemId) {
throw new Error(ErrorType.ITEM_ID_NOT_FOUND.message(row.type, row.name, row.specification, row.material, row.surfaceTreatmentDesc, row.manufacturer, row.zincLayer));
}
const itemType = SystemConstant.ITEM_TYPE_MAP[row.type];
// 2. 插入钢卷数据
const coilParams = {
itemId,
itemType,
materialType: row.type,
warehouseId: this.warehouseMap[row.logicWarehouse],
enterCoilNo: row.inboundCoilNo,
currentCoilNo: row.inboundCoilNo,
supplierCoilNo: row.factoryCoilNo,
grossWeight: row.weight,
netWeight: row.weight,
remark: row.remark,
dataType: SystemConstant.COIL_DATA_TYPE
};
const coilRes = await addMaterialCoil(coilParams);
if (coilRes.code !== 200) {
throw new Error(`钢卷数据插入失败:${coilRes.message || '接口返回异常'}`);
}
const coilId = coilRes.data?.coilId;
// 3. 插入待处理操作
const actionParams = {
coilId,
currentCoilNo: row.inboundCoilNo,
actionStatus: SystemConstant.PENDING_ACTION_STATUS,
itemType,
itemId,
actionType: SystemConstant.PENDING_ACTION_TYPE_RECEIVE,
operationType: SystemConstant.OPERATION_TYPE_IMPORT,
remark: `Excel导入${row.inboundCoilNo}`,
warehouseId: this.planId
};
const actionRes = await addPendingAction(actionParams);
if (actionRes.code !== 200) {
throw new Error(`待处理操作插入失败:${actionRes.message || '接口返回异常'}`);
}
} catch (error) {
throw new Error(error.message);
}
},
/**
* 根据条件查找唯一的物料ID
*/
async _findItemId(row) {
const { ITEM_TYPE_MAP, ITEM_ID_FIELD } = SystemConstant;
const itemType = ITEM_TYPE_MAP[row.type];
if (!itemType) return null;
try {
// 构建查询参数
const queryParams = {
[row.type === '原料' ? 'rawMaterialName' : 'productName']: row.name,
specification: row.specification,
material: row.material,
manufacturer: row.manufacturer,
surfaceTreatmentDesc: row.surfaceTreatmentDesc,
zincLayer: row.zincLayer
};
// 执行查询
const res = itemType === 'raw_material'
? await listRawMaterial(queryParams)
: await listProduct(queryParams);
// 空值判断函数
const isEmpty = (value) => value === null || value === undefined || value === '';
// 筛选匹配记录
const matchedRecords = res.rows.filter(item => {
if (isEmpty(row.zincLayer) && !isEmpty(item.zincLayer)) return false;
if (isEmpty(row.surfaceTreatmentDesc) && !isEmpty(item.surfaceTreatmentDesc)) return false;
if (isEmpty(row.manufacturer) && !isEmpty(item.manufacturer)) return false;
if (isEmpty(row.material) && !isEmpty(item.material)) return false;
return true;
});
// 必须返回唯一记录
if (matchedRecords.length !== 1) return null;
// 返回物料ID
return matchedRecords[0][ITEM_ID_FIELD[itemType]];
} catch (error) {
this.$message.error(`查询${row.type}信息失败:${error.message}`);
return null;
}
},
/**
* 重置所有状态(添加防呆和二次确认)
*/
async reset() {
// 操作中禁止重置
if (this.validateLoading || this.importLoading || this.importStatus === ImportStatus.PROCESSING) {
this.$message.warning('当前有操作正在进行中,无法重置');
return;
}
// 有数据时二次确认,防止误清空
if (this.file || this.tableData.length > 0 || this.errorList.length > 0) {
const confirm = await this.$confirm(
'确认重置所有状态?已选择的文件、解析的数据和校验结果将被清空!',
'重置确认',
{
confirmButtonText: '确认重置',
cancelButtonText: '取消',
type: 'info'
}
).catch(() => false);
if (!confirm) return;
}
// 重置所有状态
this.file = null;
this.rawData = [];
this.tableData = [];
this.errorList = [];
this.isValidated = false;
this.progress = 0;
this.importedCount = 0;
this.totalCount = 0;
this.importStatus = ImportStatus.IDLE;
this.importErrorMsg = '';
this.$refs.upload?.clearFiles();
this.$message.success('已重置所有状态');
},
/**
* 统一错误处理确保loading状态关闭
*/
handleError(message) {
this.$message.error(message);
this.importStatus = ImportStatus.ERROR;
this.importErrorMsg = message;
// 异常场景下强制关闭loading避免界面卡死
this.validateLoading = false;
this.importLoading = false;
}
},
mounted() {
this.getWarehouseMap();
},
};
</script>
<style scoped>
.import-wizard-container {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.file-upload-area {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap; /* 适配小屏幕 */
}
.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;
}
/* 优化按钮间距,适配小屏幕 */
@media (max-width: 768px) {
.file-upload-area {
flex-direction: column;
align-items: flex-start;
}
}
</style>