You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

338 lines
7.3 KiB

<template>
<view>
<view class="scan-container" v-show="showSM" @tap="closeScan">
<video id="video-node" ref="videoNode" style="position: absolute; left: -9999px; width: 100px; height: 100px;"
muted autoplay playsinline="true" webkit-playsinline="true"></video>
<view class="display-wrapper">
<canvas canvas-id="displayCanvas" id="displayCanvas" class="display-canvas"></canvas>
<view class="scan-mask">
<view class="scan-box">
<view class="line"></view>
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
<view class="corner bottom-right"></view>
</view>
<view class="tip" v-if="isScanning">正在扫描二维码...</view>
<button v-else class="start-btn" @tap="initScanner">点击开启摄像头</button>
</view>
</view>
<view class="bottom-bar">
<button class="action-btn" @tap="closeScan"></button>
</view>
</view>
</view>
</template>
<script>
// 请确保已安装 jsQR: npm install jsqr
import jsQR from 'jsqr';
export default {
data() {
return {
showSM: false,
videoElement: null,
canvasContext: null,
isScanning: false,
videoStream: null,
timer: null
};
},
onReady() {
// 页面准备好后先尝试初始化,如果被浏览器拦截,用户点击按钮可再次触发
// this.initScanner();
},
methods: {
/**
* 初始化扫描器
*/
async initScanner() {
if (this.isScanning) return;
try {
// 1. 获取原生 DOM 节点
// 在 UniApp H5 中,通过 document.querySelector 寻找原生 video 标签最稳妥
const video = document.querySelector('#video-node video') || document.getElementById('video-node');
this.videoElement = video;
if (!this.videoElement || typeof this.videoElement.play !== 'function') {
// 针对部分 UniApp 渲染差异的兼容处理
this.videoElement = video.getElementsByTagName('video')[0] || video;
}
// 2. 获取摄像头权限
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment", // 优先后置
width: {
ideal: 750
},
height: {
ideal: 1280
}
}
});
this.videoStream = stream;
this.videoElement.srcObject = stream;
// 3. 确保视频播放
await this.videoElement.play();
// 4. 初始化绘图上下文
this.canvasContext = document.createElement('canvas').getContext('2d', {
willReadFrequently: true
});
this.isScanning = true;
this.renderLoop();
console.log("摄像头启动成功");
} catch (err) {
console.error("启动失败详情:", err);
uni.showToast({
title: '请在HTTPS环境下点击开启',
icon: 'none',
duration: 3000
});
}
},
/**
* 渲染与解码主循环
*/
renderLoop() {
if (!this.isScanning) return;
// HAVE_ENOUGH_DATA 表示视频帧已就绪
if (this.videoElement.readyState === this.videoElement.HAVE_ENOUGH_DATA) {
const vw = this.videoElement.videoWidth;
const vh = this.videoElement.videoHeight;
// 绘制到可见的 Canvas 上
this.drawToDisplay(vw, vh);
// 执行解码
this.decode(vw, vh);
}
// 使用 requestAnimationFrame 保持 60fps 渲染
this.timer = requestAnimationFrame(this.renderLoop);
},
/**
* 绘制可见画面
*/
drawToDisplay(vw, vh) {
const canvas = document.querySelector('#displayCanvas canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
canvas.width = screenW * dpr;
canvas.height = screenH * dpr;
// 计算 Object-Fit: Cover
const scale = Math.max(canvas.width / vw, canvas.height / vh);
const x = (canvas.width / 1.5) - (vw / 1.5) * scale;
const y = (canvas.height / 1.5) - (vh / 1.5) * scale;
ctx.drawImage(this.videoElement, x, y, vw * scale, vh * scale);
},
/**
* jsQR 识别逻辑
*/
decode(vw, vh) {
// 降低解码频率可提升低端机性能,这里我们全速运行,若卡顿可加计数器
this.canvasContext.canvas.width = vw;
this.canvasContext.canvas.height = vh;
this.canvasContext.drawImage(this.videoElement, 0, 0, vw, vh);
const imageData = this.canvasContext.getImageData(0, 0, vw, vh);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
// console.log(code,"aaa");
if (code && code.data) {
this.stopScanning();
this.handleSuccess(code.data);
}
},
handleSuccess(result) {
uni.vibrateShort();
uni.showModal({
title: '扫码结果',
content: result,
confirmText: '确定',
showCancel: false,
success: () => {
this.$emit('scanSuccess', result);
this.showSM = false;
this.stopScanning();
// uni.navigateBack();
}
});
},
stopScanning() {
this.isScanning = false;
if (this.timer) cancelAnimationFrame(this.timer);
if (this.videoStream) {
this.videoStream.getTracks().forEach(track => track.stop());
}
},
showScan() {
this.showSM = true;
// 开始扫码
this.initScanner();
},
closeScan() {
this.showSM = false;
this.stopScanning();
// uni.navigateBack();
}
},
beforeDestroy() {
this.stopScanning();
}
};
</script>
<style scoped>
.scan-container {
position: fixed;
inset: 0;
background: #000;
z-index: 999;
}
.display-wrapper {
position: relative;
width: 100vw;
height: 100vh;
}
.display-canvas {
width: 100%;
height: 100%;
}
/* 装饰层 */
.scan-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.scan-box {
position: relative;
width: 480rpx;
height: 480rpx;
box-shadow: 0 0 0 1000px rgba(0, 0, 0, 0.4);
/* 形成中间镂空感 */
background: transparent;
}
.corner {
position: absolute;
width: 30rpx;
height: 30rpx;
border: 6rpx solid #007aff;
}
.top-left {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.top-right {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
.bottom-left {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
.bottom-right {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(to right, transparent, #007aff, transparent);
animation: scanMove 2s infinite linear;
}
@keyframes scanMove {
0% {
top: 0;
}
100% {
top: 100%;
}
}
.tip {
margin-top: 80rpx;
color: #fff;
font-size: 28rpx;
}
.start-btn {
margin-top: 80rpx;
background: #007aff;
color: #fff;
border-radius: 40rpx;
font-size: 28rpx;
padding: 0 40rpx;
}
.bottom-bar {
position: absolute;
bottom: 100rpx;
width: 100%;
display: flex;
justify-content: center;
}
.action-btn {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1px solid #fff;
font-size: 26rpx;
border-radius: 40rpx;
}
</style>