357 lines
7.5 KiB
Vue
357 lines
7.5 KiB
Vue
<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> |