修改条形码为二维码,同时对于扫码展示部分进行修改
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
|
||||
<div class="focus-tip" v-if="!errorMsg && !isPaused">
|
||||
<div class="scan-line"></div>
|
||||
<div class="scan-text">请将条码横向填满红框</div>
|
||||
<div class="scan-text">将条码置于镜头范围内即可</div>
|
||||
</div>
|
||||
|
||||
<div class="focus-tip success" v-if="isPaused">
|
||||
@ -15,25 +15,43 @@
|
||||
扫描成功,3秒后继续...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasZoom" class="zoom-control">
|
||||
<span class="zoom-icon">-</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
step="0.1"
|
||||
v-model="currentZoom"
|
||||
@input="handleZoom"
|
||||
/>
|
||||
<span class="zoom-icon">+</span>
|
||||
<div class="zoom-value">{{ currentZoom }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue' // 引入图标用于成功提示
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['decode', 'error'])
|
||||
|
||||
const errorMsg = ref('')
|
||||
const isPaused = ref(false) // ★ 新增:控制暂停状态
|
||||
const isPaused = ref(false)
|
||||
let html5QrCode: Html5Qrcode | null = null
|
||||
const scannerElementId = "qr-reader"
|
||||
|
||||
// 变焦控制状态
|
||||
const hasZoom = ref(false)
|
||||
const zoomMin = ref(1)
|
||||
const zoomMax = ref(5)
|
||||
const currentZoom = ref(1)
|
||||
|
||||
const startScanning = async () => {
|
||||
try {
|
||||
// 1. 实例化
|
||||
html5QrCode = new Html5Qrcode(scannerElementId, {
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
@ -43,37 +61,34 @@ const startScanning = async () => {
|
||||
verbose: false
|
||||
})
|
||||
|
||||
// 2. 启动配置
|
||||
const config = {
|
||||
fps: 20,
|
||||
qrbox: { width: 320, height: 60 },
|
||||
// ★★★ 核心修改点 2:移除了 qrbox 属性 ★★★
|
||||
// 移除后,库默认会对每一帧的“全画面”进行解析,不再局限于中间区域
|
||||
// qrbox: { width: 300, height: 100 },
|
||||
|
||||
disableFlip: false,
|
||||
videoConstraints: {
|
||||
facingMode: "environment",
|
||||
width: { min: 1280, ideal: 1920, max: 3840 },
|
||||
height: { min: 720, ideal: 1080, max: 2160 },
|
||||
// 保持高分辨率以支持微小条码
|
||||
width: { min: 1280, ideal: 3840, max: 3840 },
|
||||
height: { min: 720, ideal: 2160, max: 2160 },
|
||||
focusMode: "continuous",
|
||||
advanced: [{ focusMode: "macro" }]
|
||||
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 启动
|
||||
await html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText) => {
|
||||
// ★ 核心修改:如果处于暂停冷却期,直接忽略后续扫描结果
|
||||
if (isPaused.value) return
|
||||
|
||||
console.log(`Scan: ${decodedText}`)
|
||||
|
||||
// 1. 锁定状态
|
||||
isPaused.value = true
|
||||
|
||||
// 2. 发送数据
|
||||
emit('decode', decodedText)
|
||||
|
||||
// 3. 开启 3 秒倒计时解锁
|
||||
if (navigator.vibrate) navigator.vibrate(200);
|
||||
|
||||
setTimeout(() => {
|
||||
isPaused.value = false
|
||||
}, 3000)
|
||||
@ -82,20 +97,52 @@ const startScanning = async () => {
|
||||
// ignore
|
||||
}
|
||||
)
|
||||
|
||||
checkZoomCapability()
|
||||
|
||||
} catch (err: any) {
|
||||
let msg = '无法启动摄像头'
|
||||
const errStr = err.toString()
|
||||
if (errStr.includes('Permission')) msg = '请允许摄像头权限'
|
||||
else if (errStr.includes('Secure')) msg = '需要 HTTPS 或 localhost'
|
||||
else if (errStr.includes('NotFound')) msg = '未检测到后置摄像头'
|
||||
else if (errStr.includes('OverconstrainedError')) msg = '摄像头不支持高分辨率'
|
||||
|
||||
console.error("Scanner Error:", err)
|
||||
errorMsg.value = msg
|
||||
emit('error', msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测硬件变焦能力
|
||||
const checkZoomCapability = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
|
||||
|
||||
// @ts-ignore
|
||||
if (videoTrack && 'zoom' in videoTrack) {
|
||||
hasZoom.value = true
|
||||
// @ts-ignore
|
||||
zoomMin.value = videoTrack.zoom.min || 1
|
||||
// @ts-ignore
|
||||
zoomMax.value = videoTrack.zoom.max || 5
|
||||
// @ts-ignore
|
||||
currentZoom.value = videoTrack.zoom.min || 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("无法获取变焦能力", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滑块拖动
|
||||
const handleZoom = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
html5QrCode.applyVideoConstraints({
|
||||
advanced: [{ zoom: Number(currentZoom.value) }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("变焦失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
const stopScanning = async () => {
|
||||
if (html5QrCode) {
|
||||
try {
|
||||
@ -131,7 +178,8 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
/* 如果是全屏模式,这里不需要圆角,或者保持圆角视你的UI设计而定 */
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.scanner-box {
|
||||
@ -151,7 +199,6 @@ onUnmounted(() => {
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
display: block !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
@ -167,62 +214,98 @@ onUnmounted(() => {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* --- 视觉辅助线 --- */
|
||||
/* --- ★ 修改点 3:视觉层 CSS 更新 --- */
|
||||
.focus-tip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 320px;
|
||||
height: 60px;
|
||||
border: 2px solid rgba(255, 0, 0, 0.6);
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 移除了 border 和 box-shadow,不再显示红框和黑色遮罩 */
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* ★ 扫描成功时的绿色框样式 */
|
||||
.focus-tip.success {
|
||||
border-color: #67c23a; /* 绿色边框 */
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
background: rgba(103, 194, 58, 0.2); /* 成功时全屏微微泛绿 */
|
||||
}
|
||||
|
||||
/* 扫描线改为全屏宽度 */
|
||||
.scan-line {
|
||||
width: 95%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #ff0000;
|
||||
box-shadow: 0 0 4px #ff0000;
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
|
||||
position: absolute;
|
||||
animation: scan-move 1.5s infinite ease-in-out;
|
||||
/* 动画范围从 10% 到 90% */
|
||||
animation: scan-move 2.5s infinite linear;
|
||||
}
|
||||
|
||||
.scan-text {
|
||||
position: absolute;
|
||||
bottom: -35px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
bottom: 150px; /* 调整文字位置 */
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scan-text-success {
|
||||
color: #67c23a;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
gap: 10px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
||||
background: rgba(103, 194, 58, 0.9);
|
||||
padding: 15px 30px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
@keyframes scan-move {
|
||||
0% { top: 10%; opacity: 0.5; }
|
||||
50% { top: 90%; opacity: 1; }
|
||||
100% { top: 10%; opacity: 0.5; }
|
||||
0% { top: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* 变焦控制器 */
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zoom-control input[type=range] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.zoom-icon {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
font-size: 14px;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@ -16,18 +16,10 @@
|
||||
</template>
|
||||
|
||||
<div class="scan-section">
|
||||
<div v-if="showCamera" class="camera-wrapper">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
<div class="scan-overlay">
|
||||
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||
关闭摄像头
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||
<div class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启扫码</span>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
</div>
|
||||
|
||||
<div class="input-box">
|
||||
@ -150,6 +142,22 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||||
<div class="scanner-header">
|
||||
<el-button circle icon="Close" @click="showCamera = false" 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="cartItems.length > 0" class="current-count">当前已添加: {{ cartItems.length }} 件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -201,7 +209,7 @@ import { useUserStore } from '@/stores/user'
|
||||
const barcodeInput = ref('')
|
||||
const cartItems = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showCamera = ref(false) // ★ 核心修改:默认改为 false
|
||||
const showCamera = ref(false)
|
||||
const barcodeRef = ref()
|
||||
const formRef = ref()
|
||||
const userStore = useUserStore()
|
||||
@ -266,26 +274,21 @@ const onScanSuccess = (code: string) => {
|
||||
if (!code) return
|
||||
const trimCode = code.trim()
|
||||
|
||||
// ★★★ 核心修改:防误触校验 ★★★
|
||||
// 1. 正则校验:只允许 数字、字母、横杠、点
|
||||
// 这样可以屏蔽掉条码解析错误产生的 { } $ # 等乱码
|
||||
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||
if (!validPattern.test(trimCode)) {
|
||||
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 长度校验:避免误扫到环境中的短数字
|
||||
if (trimCode.length < 3) {
|
||||
ElMessage.warning('扫描结果过短,请对准重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖:防止同一条码连续触发
|
||||
if (loading.value) return
|
||||
|
||||
barcodeInput.value = trimCode
|
||||
handleManualInput() // 复用手动输入逻辑
|
||||
handleManualInput()
|
||||
}
|
||||
|
||||
const handleManualInput = async () => {
|
||||
@ -343,8 +346,11 @@ const handleManualInput = async () => {
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 聚焦输入框,方便连续扫
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
|
||||
// 只有在非全屏模式下才 focus
|
||||
if (!showCamera.value) {
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -505,21 +511,73 @@ onUnmounted(() => {
|
||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
||||
|
||||
/* 扫码区 */
|
||||
/* 扫码区(卡片内触发器) */
|
||||
.scan-section { margin-bottom: 20px; }
|
||||
.camera-wrapper {
|
||||
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||
}
|
||||
.scan-overlay {
|
||||
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||
}
|
||||
.camera-placeholder {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.camera-placeholder:active { background: #e6e8eb; }
|
||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||
|
||||
/* ★ 全屏扫码层样式 */
|
||||
.fullscreen-scanner-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scanner-header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||
|
||||
.scanner-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 强制子组件(QrScanner)填满容器 */
|
||||
:deep(.qr-scanner-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.scanner-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||
|
||||
/* 表单与购物车 */
|
||||
.cart-section { margin-bottom: 20px; }
|
||||
.form-section { background: #fff; }
|
||||
|
||||
Reference in New Issue
Block a user