refactor: upgrade stocktake scanner to fullscreen and implement scan-input-resume loop

This commit is contained in:
DXC
2026-03-13 08:47:53 +08:00
parent b091654812
commit df2fa4baf1

View File

@ -49,14 +49,33 @@
</div> </div>
</template> </template>
<div class="scanner-container"> <div class="scan-section">
<div v-if="!showList && !showQtyDialog" class="camera-active-box"> <div v-if="hasPermission" class="camera-placeholder" @click="openFullscreenScanner">
<QrScanner @decode="onScanSuccess" /> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<div class="scan-overlay-tip">描条</div> <span class="text">点击开启全屏扫码</span>
</div> </div>
<div v-else class="camera-paused-box" @click="closeOverlays"> <div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="50" color="#909399"><EditPen /></el-icon> <el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<p>操作中...<br>点击返回扫描</p> <span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!hasPermission"
>
<template #prefix>
<el-icon><EditPen /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
</template>
</el-input>
</div> </div>
</div> </div>
@ -210,6 +229,24 @@
</div> </div>
</el-drawer> </el-drawer>
<!-- 全屏扫码 Overlay -->
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="closeScanner" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="stats.scanned > 0" class="current-count">已盘点: {{ stats.scanned }} </p>
</div>
</div>
<el-dialog v-model="showFinishDialog" title="📊 盘点结算" width="90%" align-center :close-on-click-modal="false" class="preview-dialog"> <el-dialog v-model="showFinishDialog" title="📊 盘点结算" width="90%" align-center :close-on-click-modal="false" class="preview-dialog">
<div class="report-summary"> <div class="report-summary">
<div class="summary-row"><span>截止时间:</span><span>{{ new Date().toLocaleString() }}</span></div> <div class="summary-row"><span>截止时间:</span><span>{{ new Date().toLocaleString() }}</span></div>
@ -253,7 +290,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { getAllStock } from '@/api/inbound/stock' import { getAllStock } from '@/api/inbound/stock'
import QrScanner from '@/components/QrScanner/index.vue' import QrScanner from '@/components/QrScanner/index.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen } from '@element-plus/icons-vue' import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close } from '@element-plus/icons-vue'
import request from '@/utils/request' import request from '@/utils/request'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
@ -290,6 +327,12 @@ const isSessionActive = ref(false)
const serverDraftCount = ref(0) const serverDraftCount = ref(0)
const syncStatus = ref<'success' | 'syncing' | 'failed'>('success') const syncStatus = ref<'success' | 'syncing' | 'failed'>('success')
// 新增:扫码相关状态
const showCamera = ref(false)
const barcodeInput = ref('')
const barcodeRef = ref()
const hasPermission = userStore.hasPermission('inventory_stocktake:operation')
const showList = ref(false) const showList = ref(false)
const showFinishDialog = ref(false) const showFinishDialog = ref(false)
const showQtyDialog = ref(false) const showQtyDialog = ref(false)
@ -431,7 +474,7 @@ const loadData = async () => {
} }
const onScanSuccess = (code: string) => { const onScanSuccess = (code: string) => {
if (!code) return if (!code || loading.value) return
const trimCode = code.trim() const trimCode = code.trim()
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) { if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
@ -439,33 +482,65 @@ const onScanSuccess = (code: string) => {
return return
} }
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode) const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode)
if (item) { if (item) {
if (navigator.vibrate) navigator.vibrate(100) if (navigator.vibrate) navigator.vibrate(100)
const isBatchMultiple = (item.batch_no && item.batch_no.length > 0) && (item.qty_stock > 1); // ★★★ 核心修改:扫码成功后立即关闭全屏扫码,弹出填数对话框
showCamera.value = false
if (isBatchMultiple) { // 无论是否多批次,都弹出对话框让用户确认数量
openQtyDialog(item) openQtyDialog(item)
} else {
if (item.scanned) {
openQtyDialog(item)
} else {
updateAndSync(item, 1)
ElMessage.success(`自动确认: ${item.name} +1`)
}
}
} else { } else {
ElMessage.error(`不在库条码: ${trimCode}`) ElMessage.error(`不在库条码: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200]) if (navigator.vibrate) navigator.vibrate([200, 50, 200])
} }
} }
// 开启全屏扫码
const openFullscreenScanner = () => {
if (!hasPermission) {
ElMessage.warning('无扫码权限')
return
}
showCamera.value = true
}
// 关闭全屏扫码
const closeScanner = () => {
showCamera.value = false
}
// 手动输入条码
const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
loading.value = true
try {
const item = allData.value.find(i => i.uuid === code || i.bar_code === code)
if (item) {
if (navigator.vibrate) navigator.vibrate(100)
openQtyDialog(item)
} else {
ElMessage.error(`不在库条码: ${code}`)
}
} finally {
barcodeInput.value = ''
loading.value = false
}
}
const openQtyDialog = (item: StockItem) => { const openQtyDialog = (item: StockItem) => {
currentItem.value = item currentItem.value = item
inputQty.value = item.scanned ? item.qty_actual : undefined inputQty.value = item.scanned ? item.qty_actual : 1
showQtyDialog.value = true showQtyDialog.value = true
nextTick(() => { nextTick(() => {
@ -481,6 +556,13 @@ const handleManualConfirm = () => {
updateAndSync(currentItem.value, val) updateAndSync(currentItem.value, val)
showQtyDialog.value = false showQtyDialog.value = false
ElMessage.success(`已记录实盘: ${val}`) ElMessage.success(`已记录实盘: ${val}`)
// ★★★ 核心修改:确认数量后自动重新打开全屏扫码,实现无缝闭环
nextTick(() => {
if (hasPermission) {
showCamera.value = true
}
})
} }
const updateAndSync = async (item: StockItem, quantity: number) => { const updateAndSync = async (item: StockItem, quantity: number) => {
@ -761,4 +843,132 @@ const finishStocktake = async () => {
font-size: 24px !important; font-size: 24px !important;
height: 58px !important; height: 58px !important;
} }
/* ★★★ 新增:扫码区域样式 ★★★ */
.scan-section {
margin-bottom: 15px;
}
.camera-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 180px;
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
border: 2px dashed #409EFF;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:hover {
background: linear-gradient(135deg, #d9ecff 0%, #b3d8ff 100%);
transform: scale(1.01);
}
.camera-placeholder .text {
margin-top: 10px;
color: #409EFF;
font-size: 14px;
}
.input-box {
margin-top: 15px;
}
.input-box :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
.input-box :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
.input-box :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409EFF inset;
}
/* ★★★ 全屏扫码 Overlay 样式 ★★★ */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
z-index: 2000;
display: flex;
flex-direction: column;
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
}
.scanner-title {
font-size: 18px;
font-weight: bold;
}
.close-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: #fff;
}
.scanner-placeholder {
width: 40px;
}
.scanner-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-footer {
padding: 20px;
text-align: center;
background: rgba(0, 0, 0, 0.8);
color: #fff;
}
.scanner-footer p {
margin: 5px 0;
}
.current-count {
font-size: 16px;
font-weight: bold;
color: #409EFF;
}
@media (max-width: 768px) {
.app-container {
padding: 5px;
}
.title-box {
font-size: 16px;
}
.camera-placeholder {
height: 120px;
}
.bottom-actions {
flex-direction: column;
}
.bottom-actions .el-button {
width: 100%;
}
}
</style> </style>