<template>
|
<div>
|
<vol-box
|
v-model="showDetailBox"
|
:lazy="true"
|
width="65%"
|
:padding="20"
|
title="虚拟出入库"
|
class="custom-vol-box"
|
>
|
<div>
|
<!-- 单据输入区域(支持扫码) -->
|
<el-form
|
:inline="true"
|
:model="orderForm"
|
style="margin-bottom: 20px; align-items: flex-end;"
|
@submit.prevent
|
>
|
<el-form-item label="出库单据:" name="outboundOrderNo">
|
<el-input
|
v-model="orderForm.outboundOrderNo"
|
placeholder="请输入或扫描出库单据号"
|
clearable
|
style="width: 220px; margin-right: 10px;"
|
@input="handleOutboundInput"
|
@keyup.enter="(e) => {
|
e.stopPropagation(); // 阻止事件冒泡
|
e.preventDefault(); // 阻止默认行为
|
focusBarcodeInputDirectly();
|
}"
|
ref="outboundInputRef"
|
></el-input>
|
</el-form-item>
|
<el-form-item label="采购单据:" name="purchaseOrderNo">
|
<el-input
|
v-model="orderForm.purchaseOrderNo"
|
placeholder="扫码条码后自动填充"
|
clearable
|
style="width: 220px; margin-right: 10px;"
|
@input="handlePurchaseInput"
|
readonly
|
ref="purchaseInputRef"
|
></el-input>
|
</el-form-item>
|
</el-form>
|
|
<!-- 上方输入框 -->
|
<el-form
|
:inline="true"
|
:model="formData"
|
ref="formRef"
|
style="margin-bottom: 20px; align-items: flex-end;"
|
@submit.prevent
|
>
|
<el-form-item
|
label="扫描条码:"
|
style="width: 80%"
|
name="barcode"
|
:rules="[{ required: true, message: '请扫描或输入条码', trigger: 'blur' }]"
|
>
|
<el-input
|
ref="barcodeInputRef"
|
v-model="formData.barcode"
|
placeholder="请使用扫码枪扫描条码,或手动输入"
|
@keydown.enter="debouncedHandleScan"
|
autofocus
|
class="custom-input"
|
:disabled="!orderForm.outboundOrderNo || loading"
|
></el-input>
|
</el-form-item>
|
<el-form-item>
|
<el-button
|
type="primary"
|
size="small"
|
@click="handleScan"
|
class="custom-button"
|
:disabled="!orderForm.outboundOrderNo || loading"
|
>
|
<Search /> 确认扫描
|
</el-button>
|
</el-form-item>
|
</el-form>
|
|
<!-- 下方显示框 -->
|
<div class="scan-list">
|
<el-card shadow="hover" style="margin-bottom: 10px; border: none;" class="custom-card">
|
<div class="card-header">
|
<span class="header-title">已扫描条码列表(共{{ scannedBarcodes.length }}条)</span>
|
</div>
|
<div class="card-body">
|
<el-scrollbar height="400px" class="custom-scrollbar">
|
<transition-group name="barcode-item-transition">
|
<div class="barcode-item" v-for="(item, index) in scannedBarcodes" :key="item.barcode" :data-index="index">
|
<span class="barcode-text">{{ index + 1 }}. {{ item.barcode }}</span>
|
<el-button
|
class="delete-btn"
|
@click="removeItem(index, item.barcode)"
|
icon="Delete"
|
circle
|
:disabled="loading"
|
></el-button>
|
</div>
|
</transition-group>
|
<div class="empty-tip" v-if="scannedBarcodes.length === 0">
|
<span>暂无扫描记录,请先输入出库单据后扫描条码</span>
|
</div>
|
</el-scrollbar>
|
</div>
|
</el-card>
|
</div>
|
</div>
|
|
<template #footer>
|
<div class="footer-actions">
|
<el-button
|
type="primary"
|
size="small"
|
@click="submit"
|
:disabled="scannedBarcodes.length === 0 || !orderForm.outboundOrderNo || !orderForm.purchaseOrderNo || loading"
|
class="submit-btn"
|
>
|
<Check /> 提交出库
|
</el-button>
|
<el-button
|
type="text"
|
size="small"
|
@click="(e) => {
|
e.stopPropagation();
|
e.preventDefault();
|
showDetailBox = false;
|
}"
|
class="cancel-btn"
|
:disabled="loading"
|
>
|
取消
|
</el-button>
|
</div>
|
</template>
|
</vol-box>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, nextTick, watch } from 'vue';
|
import { ElMessage } from 'element-plus';
|
import { Search, Check } from '@element-plus/icons-vue';
|
|
import VolBox from "@/components/basic/VolBox.vue";
|
import http from '@/api/http';
|
|
// 响应式数据
|
const showDetailBox = ref(false);
|
const orderForm = reactive({
|
outboundOrderNo: "",
|
purchaseOrderNo: ""
|
});
|
const formData = reactive({
|
barcode: "",
|
});
|
const scannedBarcodes = ref([]);
|
const loading = ref(false);
|
// 新增:存储首次扫描的采购单号(用于一致性校验)
|
const firstPurchaseOrderNo = ref(null);
|
|
// 模板引用
|
const formRef = ref(null);
|
const barcodeInputRef = ref(null);
|
const outboundInputRef = ref(null);
|
const purchaseInputRef = ref(null);
|
|
// 组件挂载时聚焦到出库单输入框
|
onMounted(() => {
|
nextTick(() => {
|
outboundInputRef.value?.focus();
|
});
|
});
|
|
// 监听扫描列表,若为空则重置首次采购单号
|
watch(scannedBarcodes, (newVal) => {
|
if (newVal.length === 0) {
|
firstPurchaseOrderNo.value = null;
|
orderForm.purchaseOrderNo = ""; // 同步清空采购单输入框
|
}
|
}, { deep: true });
|
|
// 简单防抖函数(无需依赖lodash)
|
const debounce = (fn, delay = 100) => {
|
let timer = null;
|
return (...args) => {
|
if (timer) clearTimeout(timer);
|
timer = setTimeout(() => {
|
fn.apply(this, args);
|
}, delay);
|
};
|
};
|
|
// 打开弹窗
|
const open = () => {
|
showDetailBox.value = true;
|
scannedBarcodes.value = [];
|
formData.barcode = "";
|
orderForm.outboundOrderNo = "";
|
orderForm.purchaseOrderNo = "";
|
// 重置首次采购单号
|
firstPurchaseOrderNo.value = null;
|
nextTick(() => {
|
outboundInputRef.value?.focus();
|
});
|
};
|
|
// 出库单输入处理(扫码或手动输入)
|
const handleOutboundInput = (value) => {
|
if (value && value.trim()) {
|
// 出库单号格式验证逻辑(按需保留)
|
}
|
};
|
|
// 采购单输入处理(扫码或手动输入)
|
const handlePurchaseInput = (value) => {
|
if (value && value.trim()) {
|
// 采购单号格式验证逻辑(按需保留)
|
}
|
};
|
|
// 直接跳转到条码输入框(无需先填采购单)
|
const focusBarcodeInputDirectly = () => {
|
if (orderForm.outboundOrderNo.trim()) {
|
barcodeInputRef.value?.focus();
|
} else {
|
ElMessage.warning("请先输入有效的出库单据号");
|
}
|
};
|
|
/**
|
* 根据条码查询采购单接口(完全对齐示例请求格式)
|
* @param {string} barcode 条码
|
* @returns {Promise<string>} 采购单号
|
*/
|
const getPurchaseOrderByBarcode = async (barcode) => {
|
// 完全按照示例格式:url拼接参数 + 第二个参数传提示文本
|
const res = await http.post(`/api/OutboundPicking/GetPurchaseOrderByBarcode?barCode=${encodeURIComponent(barcode)}`, "查询采购单中...");
|
|
if (res.status !== true) {
|
throw new Error(res.message || "查询采购单失败");
|
}
|
if (!res.data?.purchaseOrderNo) {
|
throw new Error("未查询到该条码对应的采购单");
|
}
|
return res.data.purchaseOrderNo;
|
};
|
|
// 扫描条码核心逻辑
|
const handleScan = async () => {
|
if (!formRef.value) return;
|
// 验证条码必填
|
await formRef.value.validateField('barcode');
|
|
const barcode = formData.barcode.trim();
|
const outboundOrderNo = orderForm.outboundOrderNo.trim();
|
|
// 检查条码是否已扫描
|
if (scannedBarcodes.value.some(item => item.barcode === barcode)) {
|
ElMessage.warning(`条码 ${barcode} 已扫描过,请勿重复扫描`);
|
formData.barcode = "";
|
nextTick(() => barcodeInputRef.value?.focus());
|
return;
|
}
|
|
try {
|
loading.value = true;
|
|
// 步骤1:仅传条码查询采购单(使用示例格式的请求)
|
const purchaseOrderNo = await getPurchaseOrderByBarcode(barcode);
|
|
// 核心校验:采购单一致性检查
|
if (firstPurchaseOrderNo.value) {
|
// 非首次扫描,校验采购单号是否一致
|
if (purchaseOrderNo !== firstPurchaseOrderNo.value) {
|
throw new Error(`当前条码对应的采购单【${purchaseOrderNo}】与首次扫描的采购单【${firstPurchaseOrderNo.value}】不一致,禁止扫描!`);
|
}
|
} else {
|
// 首次扫描,记录采购单号
|
firstPurchaseOrderNo.value = purchaseOrderNo;
|
}
|
|
// 赋值采购单到输入框
|
orderForm.purchaseOrderNo = purchaseOrderNo;
|
ElMessage.success(`成功查询到采购单:${purchaseOrderNo}`);
|
|
// 步骤2:调用原有条码验证接口(如需对齐格式可同步修改,此处保留原有格式)
|
const validateRes = await http.post("/api/OutboundPicking/BarcodeValidate", {
|
outOder: outboundOrderNo,
|
inOder: purchaseOrderNo,
|
barCode: barcode
|
});
|
|
if (validateRes.status === true) {
|
scannedBarcodes.value.push({ barcode });
|
ElMessage.success("扫描成功");
|
formData.barcode = "";
|
} else {
|
ElMessage.error("扫描失败:" + (validateRes.message || '条码验证失败'));
|
}
|
} catch (error) {
|
// 捕获采购单不一致等错误并提示
|
ElMessage.error(error.message);
|
// 清空当前输入框,聚焦条码输入框
|
formData.barcode = "";
|
nextTick(() => barcodeInputRef.value?.focus());
|
} finally {
|
loading.value = false;
|
// 强制聚焦条码输入框,避免焦点跳到弹窗外
|
nextTick(() => {
|
if (barcodeInputRef.value) {
|
barcodeInputRef.value.focus();
|
// 清空输入框选中状态(扫码枪可能残留选中)
|
if (barcodeInputRef.value.input) {
|
barcodeInputRef.value.input.select = () => {};
|
}
|
}
|
});
|
}
|
};
|
|
// 带防抖和事件拦截的扫描处理(适配扫码枪)
|
const debouncedHandleScan = debounce(async (e) => {
|
// 阻止事件冒泡和默认行为
|
e.stopPropagation();
|
e.preventDefault();
|
await handleScan();
|
}, 100);
|
|
// 移除单条扫描记录
|
const removeItem = async (index, barcode) => {
|
try {
|
loading.value = true;
|
const res = await http.post("/api/OutboundPicking/DeleteBarcode", {
|
outOder: orderForm.outboundOrderNo,
|
inOder: orderForm.purchaseOrderNo,
|
barCode: barcode
|
});
|
|
if (res.status === true) {
|
scannedBarcodes.value.splice(index, 1);
|
ElMessage.success("删除成功");
|
// 若删除后无条码,自动重置首次采购单号和采购单输入框
|
if (scannedBarcodes.value.length === 0) {
|
firstPurchaseOrderNo.value = null;
|
orderForm.purchaseOrderNo = "";
|
}
|
} else {
|
ElMessage.error("删除失败:" + (res.message || '删除失败'));
|
}
|
} catch (error) {
|
ElMessage.error("删除条码异常:" + error.message);
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
// 提交出库
|
const submit = async () => {
|
if (scannedBarcodes.value.length === 0) {
|
ElMessage.warning("请先扫描至少一条条码");
|
return;
|
}
|
|
const barcodes = scannedBarcodes.value.map(item => item.barcode);
|
|
try {
|
loading.value = true;
|
const res = await http.post("/api/OutboundPicking/NoStockOutSubmit", {
|
OutOderSubmit: orderForm.outboundOrderNo,
|
InOderSubmit: orderForm.purchaseOrderNo,
|
BarCodeSubmit: barcodes
|
});
|
|
if (res.status === true) {
|
ElMessage.success("出库提交成功");
|
showDetailBox.value = false;
|
// 提交成功后重置状态
|
firstPurchaseOrderNo.value = null;
|
scannedBarcodes.value = [];
|
} else {
|
ElMessage.error("出库提交失败:" + (res.message || '提交失败'));
|
}
|
} catch (error) {
|
ElMessage.error("出库提交异常:" + error.message);
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
// 暴露给父组件的方法
|
defineExpose({
|
open
|
});
|
</script>
|
|
<style scoped>
|
/* 关键:定义列表项的过渡动画 */
|
.barcode-item-transition-enter-active,
|
.barcode-item-transition-leave-active {
|
transition: all 0.3s ease;
|
}
|
.barcode-item-transition-enter-from {
|
opacity: 0;
|
transform: translateY(10px);
|
}
|
.barcode-item-transition-leave-to {
|
opacity: 0;
|
transform: translateX(30px);
|
}
|
/* 确保删除时其他元素平滑上移 */
|
.barcode-item-transition-move {
|
transition: transform 1s ease;
|
}
|
|
.scan-list {
|
width: 100%;
|
}
|
|
.custom-card {
|
border-radius: 12px;
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
}
|
.custom-card:hover {
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12) !important;
|
}
|
|
.card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 15px;
|
padding-bottom: 10px;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
.header-title {
|
font-weight: 600;
|
font-size: 15px;
|
color: #333;
|
}
|
|
.card-body {
|
padding: 0;
|
}
|
|
/* 自定义滚动条 */
|
.custom-scrollbar :deep(.el-scrollbar__thumb) {
|
background: rgba(0, 0, 0, 0.2);
|
border-radius: 4px;
|
width: 4px;
|
}
|
.custom-scrollbar :deep(.el-scrollbar__bar:hover .el-scrollbar__thumb) {
|
background: rgba(0, 0, 0, 0.3);
|
width: 6px;
|
}
|
.custom-scrollbar :deep(.el-scrollbar__wrap) {
|
overflow-x: hidden;
|
}
|
|
.barcode-item {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding: 10px 15px;
|
border-bottom: 1px solid #f7f7f7;
|
transition: background-color 0.2s ease;
|
}
|
.barcode-item:hover {
|
background-color: #fafafa;
|
}
|
/* 为奇数行添加轻微的背景色,增强可读性 */
|
.barcode-item:nth-child(odd) {
|
background-color: #f9f9f9;
|
}
|
.barcode-text {
|
flex: 1;
|
font-size: 14px;
|
color: #666;
|
transition: color 0.2s;
|
}
|
.barcode-item:hover .barcode-text {
|
color: #409eff;
|
}
|
|
.delete-btn {
|
color: #ea1919;
|
font-size: 16px;
|
transition: all 0.2s;
|
opacity: 0.7;
|
}
|
.barcode-item:hover .delete-btn {
|
opacity: 1;
|
}
|
.delete-btn:hover {
|
color: #f56c6c !important;
|
background: rgba(245, 108, 108, 0.1);
|
}
|
|
.empty-tip {
|
text-align: center;
|
padding: 80px 0;
|
color: #999;
|
font-size: 14px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
gap: 15px;
|
}
|
.empty-tip i {
|
font-size: 40px;
|
color: #dcdfe6;
|
}
|
|
/* 自定义输入框 */
|
.custom-input :deep(.el-input__inner) {
|
border-radius: 6px;
|
border-color: #e4e7ed;
|
transition: all 0.3s;
|
height: 36px;
|
line-height: 36px;
|
}
|
.custom-input :deep(.el-input__inner:focus) {
|
border-color: #409eff;
|
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
}
|
|
/* 自定义按钮 */
|
.custom-button {
|
border-radius: 6px;
|
height: 36px;
|
line-height: 36px;
|
font-size: 13px;
|
font-weight: 500;
|
transition: all 0.2s;
|
}
|
.custom-button:hover {
|
transform: translateY(-1px);
|
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
|
}
|
|
.footer-actions {
|
text-align: right;
|
}
|
.submit-btn {
|
border-radius: 6px;
|
font-weight: 500;
|
padding: 10px 20px;
|
transition: all 0.2s;
|
}
|
.submit-btn:hover {
|
transform: translateY(-1px);
|
box-shadow: 0 4px 12px rgba(46, 164, 79, 0.2);
|
}
|
.cancel-btn {
|
color: #666;
|
margin-right: 10px;
|
transition: color 0.2s;
|
}
|
.cancel-btn:hover {
|
color: #333;
|
background: #f5f5f5;
|
}
|
</style>
|
|
<style>
|
/* 全局样式部分保持不变 */
|
.text-button:hover {
|
background-color: #f0f9eb !important;
|
}
|
.el-table .warning-row {
|
background: oldlace;
|
}
|
.box-table .el-table tbody tr:hover > td {
|
background-color: #d8e0d4 !important;
|
}
|
.box-table .el-table tbody tr.current-row > td {
|
background-color: #f0f9eb !important;
|
}
|
.el-table .success-row {
|
background: #f0f9eb;
|
}
|
.box-table .el-table {
|
border: 1px solid #ebeef5;
|
}
|
.box-head .el-alert__content {
|
width: 100%;
|
}
|
</style>
|