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.
339 lines
7.3 KiB
339 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) {
|
|
result=result.replace('\r','').replace('\n')
|
|
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> |