443 lines
9.9 KiB
Vue
443 lines
9.9 KiB
Vue
<template>
|
|
<view class="scan-page">
|
|
<!-- 扫码区域 -->
|
|
<view class="scan-window">
|
|
<video id="scanVideo" ref="videoRef" class="scan-video" autoplay playsinline muted></video>
|
|
<canvas id="scanCanvas" ref="canvasRef" class="hidden-canvas" style="display: none;"></canvas>
|
|
|
|
<!-- 扫描装饰 -->
|
|
<view class="scan-mask">
|
|
<view class="scan-frame">
|
|
<view class="scan-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>
|
|
|
|
<view class="scan-tip">{{ tipText }}</view>
|
|
</view>
|
|
|
|
<!-- 底部操作栏 -->
|
|
<view class="bottom-actions">
|
|
<view class="action-item" @click="chooseImage">
|
|
<uv-icon name="photo" size="28" color="#fff"></uv-icon>
|
|
<text>相册</text>
|
|
</view>
|
|
|
|
<view class="action-item" @click="toggleInput">
|
|
<uv-icon name="edit-pen" size="28" color="#fff"></uv-icon>
|
|
<text>手动输入</text>
|
|
</view>
|
|
|
|
<view class="action-item" @click="goBack">
|
|
<uv-icon name="arrow-left" size="28" color="#fff"></uv-icon>
|
|
<text>返回</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 手动输入弹窗 -->
|
|
<uv-popup ref="inputPopup" mode="center" round="16" :closeOnClickOverlay="true">
|
|
<view class="input-dialog">
|
|
<view class="dialog-title">手动输入设备号</view>
|
|
<input
|
|
v-model="manualDeviceNo"
|
|
placeholder="请输入设备上的编号"
|
|
class="device-input"
|
|
type="text"
|
|
focus
|
|
/>
|
|
<view class="dialog-btns">
|
|
<button class="cancel-btn" @click="closeInput">取消</button>
|
|
<button class="confirm-btn" @click="confirmManualInput">确定</button>
|
|
</view>
|
|
</view>
|
|
</uv-popup>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted } from 'vue';
|
|
import { getQueryString } from '@/util/index.js';
|
|
|
|
const videoRef = ref(null);
|
|
const canvasRef = ref(null);
|
|
const inputPopup = ref(null);
|
|
const manualDeviceNo = ref('');
|
|
const tipText = ref('正在启动扫描...');
|
|
const scanning = ref(false);
|
|
|
|
let stream = null;
|
|
let animationId = null;
|
|
|
|
// 动态加载解码库
|
|
const loadJsQR = () => {
|
|
return new Promise((resolve, reject) => {
|
|
if (window.jsQR) {
|
|
resolve(window.jsQR);
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
|
|
script.onload = () => resolve(window.jsQR);
|
|
script.onerror = (e) => {
|
|
console.error('jsQR 加载失败:', e);
|
|
reject(new Error('解码组件加载失败,请检查网络'));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
};
|
|
|
|
const initScan = async () => {
|
|
try {
|
|
// 1. 检查环境
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
throw new Error('您的浏览器不支持摄像头访问,请使用微信扫描或手动输入');
|
|
}
|
|
|
|
// 2. 加载解码库
|
|
const jsQR = await loadJsQR();
|
|
|
|
// 3. 启动摄像头 - 尝试逐步降低约束
|
|
let constraints = {
|
|
video: {
|
|
facingMode: 'environment',
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 }
|
|
}
|
|
};
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
} catch (e) {
|
|
console.warn('尝试理想约束失败,降级请求:', e);
|
|
// 降级:仅请求视频,不限制分辨率和模式
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
}
|
|
|
|
if (videoRef.value) {
|
|
const video = videoRef.value;
|
|
// 关键:在赋值 srcObject 之前先重置
|
|
video.pause();
|
|
video.srcObject = stream;
|
|
|
|
// 使用更稳健的事件监听
|
|
const startPlay = () => {
|
|
video.play().then(() => {
|
|
console.log('视频开始播放');
|
|
scanning.value = true;
|
|
tipText.value = '将二维码放入框内即可自动扫描';
|
|
tick(jsQR);
|
|
}).catch(e => {
|
|
console.error('视频播放 Promise 失败:', e);
|
|
// 如果是由于用户交互限制,提示用户点击
|
|
tipText.value = '请点击屏幕启动扫描';
|
|
});
|
|
};
|
|
|
|
if (video.readyState >= 2) {
|
|
startPlay();
|
|
} else {
|
|
video.onloadedmetadata = startPlay;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('摄像头初始化失败:', err);
|
|
let errMsg = '摄像头开启失败';
|
|
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
|
errMsg = '请授予摄像头访问权限后重试';
|
|
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
|
|
errMsg = '未找到可用的摄像头';
|
|
} else if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
|
errMsg = '非加密连接(HTTPS)无法开启摄像头';
|
|
}
|
|
|
|
tipText.value = errMsg;
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: errMsg,
|
|
showCancel: false,
|
|
success: () => {
|
|
// 如果失败且无法恢复,引导手动输入
|
|
toggleInput();
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const tick = (jsQR) => {
|
|
if (!scanning.value) return;
|
|
|
|
const video = videoRef.value;
|
|
const canvas = canvasRef.value;
|
|
|
|
if (video && video.readyState === video.HAVE_ENOUGH_DATA && canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.height = video.videoHeight;
|
|
canvas.width = video.videoWidth;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
|
inversionAttempts: 'dontInvert',
|
|
});
|
|
|
|
if (code && code.data) {
|
|
console.log('扫码结果:', code.data);
|
|
handleSuccess(code.data);
|
|
return; // 停止循环
|
|
}
|
|
}
|
|
animationId = requestAnimationFrame(() => tick(jsQR));
|
|
};
|
|
|
|
const handleSuccess = (result) => {
|
|
stopScan();
|
|
|
|
// 通过全局事件通知首页
|
|
uni.$emit('h5ScanSuccess', {
|
|
result: result,
|
|
scanType: 'QR_CODE'
|
|
});
|
|
|
|
uni.navigateBack();
|
|
};
|
|
|
|
const stopScan = () => {
|
|
scanning.value = false;
|
|
if (stream) {
|
|
stream.getTracks().forEach(track => track.stop());
|
|
stream = null;
|
|
}
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
animationId = null;
|
|
}
|
|
};
|
|
|
|
const chooseImage = () => {
|
|
uni.chooseImage({
|
|
count: 1,
|
|
sourceType: ['album'],
|
|
success: async (res) => {
|
|
uni.showLoading({ title: '正在识别...' });
|
|
try {
|
|
const jsQR = await loadJsQR();
|
|
const img = new Image();
|
|
img.src = res.tempFilePaths[0];
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
ctx.drawImage(img, 0, 0);
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
|
|
|
uni.hideLoading();
|
|
if (code && code.data) {
|
|
handleSuccess(code.data);
|
|
} else {
|
|
uni.showToast({ title: '未识别到二维码', icon: 'none' });
|
|
}
|
|
};
|
|
} catch (e) {
|
|
uni.hideLoading();
|
|
uni.showToast({ title: '识别出错', icon: 'none' });
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const toggleInput = () => {
|
|
inputPopup.value.open();
|
|
};
|
|
|
|
const closeInput = () => {
|
|
inputPopup.value.close();
|
|
};
|
|
|
|
const confirmManualInput = () => {
|
|
if (!manualDeviceNo.value) return;
|
|
|
|
let deviceNo = manualDeviceNo.value.trim();
|
|
if (deviceNo.includes('deviceNo=')) {
|
|
deviceNo = getQueryString(deviceNo, 'deviceNo') || deviceNo;
|
|
}
|
|
|
|
closeInput();
|
|
stopScan();
|
|
|
|
uni.$emit('h5ScanSuccess', {
|
|
result: deviceNo,
|
|
scanType: 'MANUAL'
|
|
});
|
|
|
|
uni.navigateBack();
|
|
};
|
|
|
|
const goBack = () => {
|
|
uni.navigateBack();
|
|
};
|
|
|
|
onMounted(() => {
|
|
initScan();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopScan();
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.scan-page {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: #000;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.scan-window {
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
|
|
.scan-video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
}
|
|
|
|
.scan-mask {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
|
|
.scan-frame {
|
|
width: 500rpx;
|
|
height: 500rpx;
|
|
position: relative;
|
|
box-shadow: 0 0 0 1000px rgba(0, 0, 0, 0.4);
|
|
|
|
.scan-line {
|
|
width: 100%;
|
|
height: 4rpx;
|
|
background: linear-gradient(to right, transparent, #3EAB64, transparent);
|
|
position: absolute;
|
|
top: 0;
|
|
animation: scanMove 3s linear infinite;
|
|
}
|
|
|
|
.corner {
|
|
position: absolute;
|
|
width: 40rpx;
|
|
height: 40rpx;
|
|
border: 6rpx solid #3EAB64;
|
|
}
|
|
|
|
.top-left { top: -2rpx; left: -2rpx; border-right: none; border-bottom: none; }
|
|
.top-right { top: -2rpx; right: -2rpx; border-left: none; border-bottom: none; }
|
|
.bottom-left { bottom: -2rpx; left: -2rpx; border-right: none; border-top: none; }
|
|
.bottom-right { bottom: -2rpx; right: -2rpx; border-left: none; border-top: none; }
|
|
}
|
|
}
|
|
|
|
@keyframes scanMove {
|
|
0% { top: 0; }
|
|
100% { top: 100%; }
|
|
}
|
|
|
|
.scan-tip {
|
|
position: absolute;
|
|
top: 15%;
|
|
left: 0;
|
|
right: 0;
|
|
text-align: center;
|
|
color: #fff;
|
|
font-size: 28rpx;
|
|
padding: 0 40rpx;
|
|
}
|
|
|
|
.bottom-actions {
|
|
position: absolute;
|
|
bottom: 80rpx;
|
|
left: 0;
|
|
right: 0;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
padding: 0 60rpx;
|
|
|
|
.action-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12rpx;
|
|
|
|
text {
|
|
color: #fff;
|
|
font-size: 24rpx;
|
|
}
|
|
}
|
|
}
|
|
|
|
.input-dialog {
|
|
width: 600rpx;
|
|
background: #fff;
|
|
padding: 40rpx;
|
|
border-radius: 24rpx;
|
|
|
|
.dialog-title {
|
|
font-size: 32rpx;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
margin-bottom: 40rpx;
|
|
}
|
|
|
|
.device-input {
|
|
height: 88rpx;
|
|
background: #F8F9FA;
|
|
border-radius: 12rpx;
|
|
padding: 0 24rpx;
|
|
font-size: 28rpx;
|
|
border: 1rpx solid #eee;
|
|
margin-bottom: 40rpx;
|
|
}
|
|
|
|
.dialog-btns {
|
|
display: flex;
|
|
gap: 20rpx;
|
|
|
|
button {
|
|
flex: 1;
|
|
height: 80rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 28rpx;
|
|
border-radius: 40rpx;
|
|
border: none;
|
|
}
|
|
|
|
.cancel-btn {
|
|
background: #f5f5f5;
|
|
color: #666;
|
|
}
|
|
|
|
.confirm-btn {
|
|
background: #3EAB64;
|
|
color: #fff;
|
|
}
|
|
}
|
|
}
|
|
</style>
|