Files
KCGL/inventory-web/src/components/QrScanner/index.vue

357 lines
7.5 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="qr-scanner-container">
<div id="qr-reader" class="scanner-box"></div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<div class="focus-tip" v-if="!errorMsg && !isPaused">
<div class="scan-line"></div>
<div class="scan-text">将条码置于镜头范围内即可</div>
</div>
<div class="focus-tip success" v-if="isPaused">
<div class="scan-text-success">
<el-icon><CircleCheckFilled /></el-icon>
扫描成功2秒后继续...
</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'
const emit = defineEmits(['decode', 'error'])
const errorMsg = ref('')
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)
// 音频上下文
let audioCtx: AudioContext | null = null;
// 提示音播放函数
const playBeep = () => {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
if (!audioCtx) {
audioCtx = new AudioContext();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
// 三角波,清脆响亮
oscillator.type = 'triangle';
oscillator.frequency.value = 1500;
gainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.1);
} catch (e) {
console.error("播放提示音失败:", e);
}
};
const startScanning = async () => {
try {
html5QrCode = new Html5Qrcode(scannerElementId, {
useBarCodeDetectorIfSupported: true,
formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.QR_CODE
],
verbose: false
})
const config = {
fps: 20,
disableFlip: false,
videoConstraints: {
facingMode: "environment",
// ★★★ 核心修改:设置为 2K (QHD) 分辨率 ★★★
// min: 1280x720 (保证低端机能启动)
// ideal: 2560x1440 (2K QHD清晰度与性能的平衡点)
width: { min: 1280, ideal: 2560, max: 3840 },
height: { min: 720, ideal: 1440, max: 2160 },
// 16:9 的比例
aspectRatio: { ideal: 1.7777777778 },
focusMode: "continuous",
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
}
}
await html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText) => {
if (isPaused.value) return
console.log(`Scan: ${decodedText}`)
isPaused.value = true
playBeep();
emit('decode', decodedText)
if (navigator.vibrate) navigator.vibrate(200);
setTimeout(() => {
isPaused.value = false
}, 2000)
},
(errorMessage) => {
// ignore
}
)
checkZoomCapability()
} catch (err: any) {
let msg = '无法启动摄像头'
console.error("Scanner Error:", err)
if (err.name === 'OverconstrainedError') {
msg = '摄像头不支持 2K 分辨率,请尝试降低配置'
}
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 {
if (html5QrCode.isScanning) {
await html5QrCode.stop()
}
html5QrCode.clear()
} catch (e) {
console.error("Stop failed", e)
}
}
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
}
onMounted(() => {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (AudioContext) audioCtx = new AudioContext();
} catch(e) {}
setTimeout(() => {
startScanning()
}, 500)
})
onUnmounted(() => {
stopScanning()
})
</script>
<style scoped>
.qr-scanner-container {
width: 100%;
height: 100%;
position: relative;
background-color: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 0;
}
.scanner-box {
width: 100%;
height: 100%;
}
:deep(#qr-reader) {
width: 100%;
height: 100%;
border: none !important;
padding: 0 !important;
}
:deep(#qr-reader video) {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
.error-msg {
position: absolute;
top: 50%;
left: 0;
width: 100%;
text-align: center;
color: #fff;
background: rgba(245, 108, 108, 0.85);
padding: 15px;
transform: translateY(-50%);
z-index: 20;
}
.focus-tip {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
}
.focus-tip.success {
background: rgba(103, 194, 58, 0.2);
}
.scan-line {
width: 100%;
height: 2px;
background: rgba(255, 0, 0, 0.5);
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
position: absolute;
animation: scan-move 2.5s infinite linear;
}
.scan-text {
position: absolute;
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: #fff;
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
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: 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>