<template>
|
<div>
|
<vol-box
|
v-model="showDetailBox"
|
:lazy="true"
|
width="65%"
|
:padding="20"
|
title="虚拟出入库"
|
class="custom-vol-box"
|
>
|
<!-- 提交遮罩层:覆盖整个弹窗内容区域 -->
|
<div class="submit-mask" v-show="submitLoading">
|
<div class="mask-content">
|
<el-loading-spinner class="loading-icon" />
|
<span class="loading-text">正在提交出库,请稍候...</span>
|
</div>
|
</div>
|
|
<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="validateOutboundOrder"
|
ref="outboundInputRef"
|
:disabled="loading || submitLoading || isOutboundVerified"
|
></el-input>
|
<span v-if="isOutboundVerified" class="verified-tag">✓ 已验证</span>
|
<span v-else-if="loading" class="loading-tag">✦ 验证中...</span>
|
</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"
|
:disabled="submitLoading"
|
></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"
|
>
|
<el-input
|
ref="barcodeInputRef"
|
v-model="formData.barcode"
|
placeholder="请使用扫码枪扫描条码,或手动输入"
|
@keydown.enter="debouncedHandleScan"
|
autofocus
|
class="custom-input"
|
:disabled="!isOutboundVerified || loading || submitLoading"
|
></el-input>
|
</el-form-item>
|
<el-form-item>
|
<el-button
|
type="primary"
|
size="small"
|
@click="handleScan"
|
class="custom-button"
|
:disabled="!isOutboundVerified || loading || submitLoading"
|
>
|
<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}-${index}`">
|
<div class="barcode-detail">
|
<div class="detail-row"><span class="label">条码:</span><span class="value">{{ item.barcode || '-' }}</span></div>
|
<div class="detail-row"><span class="label">物料编码:</span><span class="value">{{ item.materielCode || '-' }}</span></div>
|
<div class="detail-row"><span class="label">物料名称:</span><span class="value">{{ item.materielName || '-' }}</span></div>
|
<div class="detail-row"><span class="label">批次号:</span><span class="value">{{ item.batchNo || '-' }}</span></div>
|
<div class="detail-row"><span class="label">条码数量:</span><span class="value">{{ item.orderQuantity || item.quantity || 0 }}</span></div>
|
<div class="detail-row"><span class="label">供应商编码:</span><span class="value">{{ item.supplyCode || '-' }}</span></div>
|
<div class="detail-row"><span class="label">采购单号:</span><span class="value">{{ item.purchaseOrderNo || '-' }}</span></div>
|
</div>
|
<el-button
|
class="delete-btn"
|
@click="removeItem(index, item.barcode)"
|
icon="Delete"
|
circle
|
:disabled="loading || submitLoading"
|
></el-button>
|
</div>
|
</transition-group>
|
<div class="empty-tip" v-if="scannedBarcodes.length === 0">
|
<span v-if="isOutboundVerified">暂无扫描记录,请扫描条码</span>
|
<span v-else>请先输入并验证有效的出库单据号</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 || !isOutboundVerified || loading || submitLoading"
|
class="submit-btn"
|
>
|
<Check /> 提交出库
|
</el-button>
|
<el-button
|
type="text"
|
size="small"
|
@click="handleCancel"
|
class="cancel-btn"
|
:disabled="loading || submitLoading"
|
>
|
取消
|
</el-button>
|
</div>
|
</template>
|
</vol-box>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, onBeforeUnmount, 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 submitLoading = ref(false);
|
const isOutboundVerified = ref(false);
|
let unlockCalled = false; // 防止重复解锁
|
|
// 模板引用
|
const formRef = ref(null);
|
const barcodeInputRef = ref(null);
|
const outboundInputRef = ref(null);
|
const purchaseInputRef = ref(null);
|
|
// 音频资源
|
const successAudioSrc = require('@/assets/audio/success.mp3');
|
const errorAudioSrc = require('@/assets/audio/error.mp3');
|
const playAudio = (src, volume = 0.8) => {
|
try { const audio = new Audio(src); audio.volume = volume; audio.play().catch(() => {}); } catch(e) {}
|
};
|
const playSuccess = () => playAudio(successAudioSrc);
|
const playError = () => playAudio(errorAudioSrc);
|
|
// ========== 解锁核心逻辑(使用新接口 MovePickingOrders) ==========
|
const unlockOutboundOrder = async () => {
|
if (!isOutboundVerified.value || !orderForm.outboundOrderNo?.trim()) return;
|
if (unlockCalled) return;
|
unlockCalled = true;
|
const outboundOrderNo = orderForm.outboundOrderNo.trim();
|
try {
|
// 替换为 MovePickingOrders 接口
|
await http.post(`/api/OutboundPicking/MovePickingOrders?outOrder=${outboundOrderNo}`, null, "解锁出库单中...").catch(() => {});
|
} catch (error) {
|
// 静默失败
|
}
|
};
|
|
// 页面关闭/刷新时的同步解锁(使用 sendBeacon 或 fetch keepalive)
|
const unlockOnPageUnload = () => {
|
if (!isOutboundVerified.value || !orderForm.outboundOrderNo?.trim()) return;
|
const outboundOrderNo = orderForm.outboundOrderNo.trim();
|
const url = `/api/OutboundPicking/MovePickingOrders?outOrder=${encodeURIComponent(outboundOrderNo)}`;
|
// 使用 fetch keepalive 发送
|
fetch(url, { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' } }).catch(() => {});
|
};
|
|
// 监听弹窗关闭(任何方式:叉号、ESC、遮罩、取消按钮等)
|
watch(showDetailBox, (newVal, oldVal) => {
|
if (oldVal === true && newVal === false) {
|
// 弹窗关闭时调用解锁
|
unlockOutboundOrder();
|
}
|
});
|
|
// 取消按钮
|
const handleCancel = (e) => {
|
e?.stopPropagation();
|
e?.preventDefault();
|
showDetailBox.value = false;
|
};
|
|
// 打开弹窗(重置所有状态)
|
const open = () => {
|
unlockCalled = false;
|
showDetailBox.value = true;
|
scannedBarcodes.value = [];
|
formData.barcode = "";
|
orderForm.outboundOrderNo = "";
|
orderForm.purchaseOrderNo = "";
|
submitLoading.value = false;
|
isOutboundVerified.value = false;
|
nextTick(() => outboundInputRef.value?.focus());
|
};
|
|
// 验证出库单号(仍使用原接口,此接口用于验证单据有效性)
|
const validateOutboundOrder = async () => {
|
const outboundOrderNo = orderForm.outboundOrderNo.trim();
|
if (!outboundOrderNo) { ElMessage.warning("请输入出库单据号"); return; }
|
try {
|
loading.value = true;
|
const res = await http.post(`/api/OutboundPicking/GetAvailablePickingOrders?outOrder=${outboundOrderNo}`, "验证出库单据号中...");
|
if (res.status !== true) {
|
orderForm.outboundOrderNo = "";
|
isOutboundVerified.value = false;
|
ElMessage.error(res.message || "出库单据号验证失败");
|
nextTick(() => outboundInputRef.value?.focus());
|
return;
|
}
|
isOutboundVerified.value = true;
|
ElMessage.success("出库单据号验证通过");
|
nextTick(() => barcodeInputRef.value?.focus());
|
} catch (error) {
|
orderForm.outboundOrderNo = "";
|
isOutboundVerified.value = false;
|
ElMessage.error(`验证异常:${error.message || "网络错误"}`);
|
nextTick(() => outboundInputRef.value?.focus());
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
const handleOutboundInput = (val) => { if (val?.trim()) isOutboundVerified.value = false; };
|
const handlePurchaseInput = (val) => {};
|
|
// 获取采购单号
|
const getPurchaseOrderByBarcode = async (barcode) => {
|
const res = await http.post(`/api/OutboundPicking/GetPurchaseOrderByBarcode?barCode=${encodeURIComponent(barcode)}`, "查询采购单号中...");
|
if (res.status !== true) throw new Error(res.message || "查询失败");
|
let purchaseOrderNo = '';
|
if (Array.isArray(res.data) && res.data.length > 0) purchaseOrderNo = res.data[0].purchaseOrderNo || res.data[0].orderId;
|
else purchaseOrderNo = res.data?.purchaseOrderNo || res.data?.orderId;
|
return purchaseOrderNo;
|
};
|
|
// 扫描条码
|
const handleScan = async () => {
|
if (!isOutboundVerified.value) {
|
ElMessage.warning("请先验证出库单据号");
|
playError();
|
nextTick(() => outboundInputRef.value?.focus());
|
return;
|
}
|
const barcode = formData.barcode.trim();
|
if (!barcode) return;
|
const isDuplicate = scannedBarcodes.value.some(item => item.barcode === barcode);
|
if (isDuplicate) {
|
ElMessage.warning(`条码【${barcode}】已存在`);
|
playError();
|
formData.barcode = "";
|
nextTick(() => barcodeInputRef.value?.focus());
|
return;
|
}
|
try {
|
loading.value = true;
|
let purchaseOrderNo = '';
|
try {
|
purchaseOrderNo = await getPurchaseOrderByBarcode(barcode);
|
if (purchaseOrderNo) orderForm.purchaseOrderNo = purchaseOrderNo;
|
} catch (e) { ElMessage.info("未查询到采购单号,继续验证条码"); playError(); }
|
const validateRes = await http.post("/api/OutboundPicking/BarcodeValidate", {
|
outOder: orderForm.outboundOrderNo,
|
inOder: purchaseOrderNo || orderForm.purchaseOrderNo,
|
barCode: barcode
|
});
|
if (validateRes.status === true && Array.isArray(validateRes.data) && validateRes.data.length) {
|
const newItems = validateRes.data.map(item => ({
|
barcode: item.barcode || '',
|
materielCode: item.materielCode || '',
|
materielName: item.materielName || '',
|
batchNo: item.batchNo || '',
|
orderQuantity: item.orderQuantity || item.quantity || 0,
|
supplyCode: item.supplyCode || '',
|
purchaseOrderNo: purchaseOrderNo || ''
|
}));
|
scannedBarcodes.value.push(...newItems);
|
ElMessage.success(`扫描成功,新增 ${newItems.length} 条`);
|
playSuccess();
|
formData.barcode = "";
|
} else {
|
ElMessage.error(validateRes.message || '条码验证失败');
|
playError();
|
formData.barcode = "";
|
}
|
} catch (error) {
|
ElMessage.error('条码验证异常');
|
playError();
|
formData.barcode = "";
|
} finally {
|
loading.value = false;
|
nextTick(() => barcodeInputRef.value?.focus());
|
}
|
};
|
|
const debounce = (fn, delay = 100) => {
|
let timer = null;
|
return (...args) => { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); };
|
};
|
const debouncedHandleScan = debounce(async (e) => { e.stopPropagation(); e.preventDefault(); await handleScan(); }, 100);
|
|
// 删除条码
|
const removeItem = async (index, barcode) => {
|
try {
|
loading.value = true;
|
const currentItem = scannedBarcodes.value[index];
|
const res = await http.post("/api/OutboundPicking/DeleteBarcode", {
|
outOder: orderForm.outboundOrderNo,
|
inOder: currentItem?.purchaseOrderNo || orderForm.purchaseOrderNo,
|
barCode: barcode
|
});
|
if (res.status === true) {
|
scannedBarcodes.value.splice(index, 1);
|
ElMessage.success("删除成功");
|
if (scannedBarcodes.value.length === 0) orderForm.purchaseOrderNo = "";
|
} else {
|
ElMessage.error("删除失败:" + (res.message || ''));
|
}
|
} catch (error) {
|
ElMessage.error("删除异常");
|
} finally {
|
loading.value = false;
|
nextTick(() => barcodeInputRef.value?.focus());
|
}
|
};
|
|
// 提交出库
|
const submit = async () => {
|
if (!isOutboundVerified.value) {
|
ElMessage.warning("出库单据号未验证");
|
nextTick(() => outboundInputRef.value?.focus());
|
return;
|
}
|
if (scannedBarcodes.value.length === 0) {
|
ElMessage.warning("请先扫描条码");
|
nextTick(() => barcodeInputRef.value?.focus());
|
return;
|
}
|
const barcodes = scannedBarcodes.value.map(item => item.barcode);
|
const purchaseOrderNos = [...new Set(scannedBarcodes.value.map(item => item.purchaseOrderNo).filter(Boolean))];
|
try {
|
submitLoading.value = true;
|
const res = await http.post("/api/OutboundPicking/NoStockOutSubmit", {
|
OutOderSubmit: orderForm.outboundOrderNo,
|
InOderSubmit: purchaseOrderNos.join(',') || '',
|
BarCodeSubmit: barcodes
|
});
|
if (res.status === true) {
|
ElMessage.success("出库提交成功");
|
unlockOutboundOrder();
|
showDetailBox.value = false; // 触发 watch 解锁(但提交成功后通常不需要解锁,因为已经出库了,不过后端应自行处理状态)
|
// 注意:提交成功后不应该再调用解锁接口,因为单据已处理完毕。这里通过标志避免:submit 成功后重置验证状态
|
isOutboundVerified.value = false;
|
unlockCalled = true; // 防止 watch 再次调用解锁
|
} else {
|
unlockOutboundOrder();
|
ElMessage.error("出库提交失败:" + (res.message || ''));
|
nextTick(() => barcodeInputRef.value?.focus());
|
}
|
} catch (error) {
|
unlockOutboundOrder();
|
ElMessage.error("提交异常:" + error.message);
|
nextTick(() => barcodeInputRef.value?.focus());
|
} finally {
|
submitLoading.value = false;
|
loading.value = false;
|
}
|
};
|
|
// 生命周期:添加页面关闭时的解锁监听
|
onMounted(() => {
|
window.addEventListener('beforeunload', unlockOnPageUnload);
|
});
|
onBeforeUnmount(() => {
|
window.removeEventListener('beforeunload', unlockOnPageUnload);
|
// 组件卸载时如果弹窗还开着,也尝试解锁(但通常页面关闭会触发 beforeunload)
|
if (showDetailBox.value) {
|
unlockOutboundOrder();
|
}
|
});
|
|
// 暴露方法
|
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;
|
}
|
|
/* 提交遮罩层样式 */
|
.submit-mask {
|
position: absolute;
|
top: 0;
|
left: 0;
|
width: 100%;
|
height: 100%;
|
background-color: rgba(255, 255, 255, 0.85);
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
z-index: 100;
|
border-radius: inherit;
|
}
|
.mask-content {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
gap: 12px;
|
color: #409eff;
|
font-size: 15px;
|
}
|
.loading-icon {
|
font-size: 24px;
|
animation: el-loading-circle 1.5s linear infinite;
|
}
|
|
/* 验证状态标签样式 */
|
.verified-tag {
|
color: #67c23a;
|
font-size: 12px;
|
margin-left: 8px;
|
font-weight: 500;
|
}
|
.loading-tag {
|
color: #409eff;
|
font-size: 12px;
|
margin-left: 8px;
|
font-weight: 500;
|
animation: spin 1s linear infinite;
|
}
|
@keyframes spin {
|
0% { transform: rotate(0deg); }
|
100% { transform: rotate(360deg); }
|
}
|
|
.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: flex-start;
|
padding: 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-detail {
|
flex: 1;
|
display: grid;
|
grid-template-columns: repeat(4, 1fr);
|
gap: 8px 15px;
|
font-size: 14px;
|
}
|
@media (max-width: 1200px) {
|
.barcode-detail {
|
grid-template-columns: repeat(3, 1fr);
|
}
|
}
|
@media (max-width: 992px) {
|
.barcode-detail {
|
grid-template-columns: repeat(2, 1fr);
|
}
|
}
|
.detail-row {
|
display: flex;
|
align-items: center;
|
}
|
.label {
|
color: #999;
|
margin-right: 5px;
|
white-space: nowrap;
|
}
|
.value {
|
color: #666;
|
flex: 1;
|
word-break: break-all;
|
}
|
.delete-btn {
|
color: #ea1919;
|
font-size: 16px;
|
transition: all 0.2s;
|
opacity: 0.7;
|
margin-left: 10px;
|
flex-shrink: 0;
|
}
|
.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>
|