Forráskód Böngészése

fix(uniapp-scan): H5 端扫码改用浏览器原生 BarcodeDetector + <video> 预览

原代码:#ifdef APP-PLUS || MP-WEIXIN 才调 uni.scanCode,H5 端啥都不做
导致 H5 浏览器(尤其 iOS Safari)扫码页黑屏+摄像头不调起,只能手动输入

修法:H5 端走浏览器原生 BarcodeDetector API:
1. navigator.mediaDevices.getUserMedia({ video: facingMode: 'environment' })
   申请后置摄像头
2. 动态创建 <video playsinline> 元素显示预览(iOS 必须 playsinline 防止全屏)
3. requestAnimationFrame + BarcodeDetector.detect() 循环识别 QR
4. 命中后停摄像头 + 走原有 verifyQrCode 校验流程
5. 浏览器不支持时(老 iOS Safari < 16.4)tipText 提示降级到手动输入

APP/小程序端保持原 uni.scanCode 逻辑不受影响

影响:inspection/scan + qualityInspection/scan 两边
yangjingjing 1 napja
szülő
commit
f3a4d390f0

+ 125 - 5
src/pages/inspection/scan/index.vue

@@ -1,5 +1,10 @@
 <template>
   <view class="scan-page">
+    <!-- H5 端摄像头预览容器(由 JS 动态创建 video) -->
+    <!-- #ifdef H5 -->
+    <view id="h5-video-wrap" class="h5-video-wrap"></view>
+    <!-- #endif -->
+
     <!-- 顶部黑色遮罩 -->
     <view class="overlay top-overlay"></view>
     <view class="overlay bottom-overlay"></view>
@@ -16,7 +21,7 @@
     </view>
 
     <view class="scan-tip">
-      <text>请将摄像头对准巡检点位的二维码</text>
+      <text>{{ tipText }}</text>
     </view>
 
     <view class="scan-btn-row">
@@ -26,7 +31,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, onUnmounted } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import {
   calculateDistance,
@@ -37,6 +42,7 @@ import { verifyQrCode } from '@/api/inspection'
 
 const taskId = ref<number>(0)
 const scanResult = ref<string>('')
+const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
 
 onLoad((q) => {
   taskId.value = Number(q?.taskId || 0)
@@ -44,10 +50,12 @@ onLoad((q) => {
 })
 
 let scanTimer: any = null
+let h5Stream: MediaStream | null = null
+let h5RafId: number | null = null
 
 function startScan() {
-  // uniapp 跨端扫码(仅 APP 与小程序支持原生扫码,H5 退化为手动输入)
   // #ifdef APP-PLUS || MP-WEIXIN
+  // APP/小程序:调起原生扫码(uni.scanCode 会自动开摄像头 + 弹原生 UI)
   scanTimer = setTimeout(() => {
     uni.scanCode({
       onlyFromCamera: true,
@@ -57,15 +65,112 @@ function startScan() {
         onScanSuccess(res.result)
       },
       fail: () => {
-        // 扫码失败:用户取消
+        // 用户取消
       }
     })
   }, 300)
   // #endif
+
+  // #ifdef H5
+  // H5 端:用浏览器原生 BarcodeDetector + <video> 预览
+  // 浏览器不支持(iOS Safari < 16.4)→ 提示降级到"手动输入"
+  h5StartBarcodeScanner()
+  // #endif
+}
+
+// ============================== H5 端扫码实现 ==============================
+async function h5StartBarcodeScanner() {
+  // 1. 能力检测
+  const BarcodeDetectorCtor: any = (window as any).BarcodeDetector
+  if (typeof BarcodeDetectorCtor !== 'function') {
+    tipText.value = '当前浏览器不支持原生扫码,请点击下方"手动输入"'
+    return
+  }
+  // 2. 检测摄像头权限 API
+  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+    tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
+    return
+  }
+  try {
+    // 3. 申请摄像头(优先后置)
+    h5Stream = await navigator.mediaDevices.getUserMedia({
+      video: { facingMode: { ideal: 'environment' } },
+      audio: false
+    })
+  } catch (e: any) {
+    if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
+      tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
+    } else if (e?.name === 'NotFoundError') {
+      tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
+    } else {
+      tipText.value = '摄像头启动失败,请点击下方"手动输入"'
+    }
+    console.warn('[H5 扫码] getUserMedia 失败', e)
+    return
+  }
+
+  // 4. 创建 <video> 元素并播放
+  const wrap = document.getElementById('h5-video-wrap')
+  if (!wrap) return
+  wrap.innerHTML = ''
+  const video = document.createElement('video')
+  video.setAttribute('playsinline', 'true') // iOS 防止自动全屏
+  video.setAttribute('autoplay', 'true')
+  video.setAttribute('muted', 'true')
+  video.muted = true
+  video.style.cssText =
+    'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;'
+  video.srcObject = h5Stream
+  wrap.appendChild(video)
+  try {
+    await video.play()
+  } catch (e) {
+    console.warn('[H5 扫码] video.play 失败', e)
+  }
+
+  // 5. 循环 detect 二维码
+  let detector: any
+  try {
+    detector = new BarcodeDetectorCtor({ formats: ['qr_code'] })
+  } catch (e) {
+    tipText.value = '浏览器不支持 QR 码识别,请点击下方"手动输入"'
+    return
+  }
+  const tick = async () => {
+    if (!h5Stream) return
+    try {
+      const codes = await detector.detect(video)
+      if (codes && codes.length && codes[0].rawValue) {
+        // 命中:停摄像头 + 触发回调
+        h5StopScanner()
+        scanResult.value = codes[0].rawValue
+        onScanSuccess(codes[0].rawValue)
+        return
+      }
+    } catch (e) {
+      // 单帧 detect 失败忽略(视频流可能还没准备好)
+    }
+    h5RafId = requestAnimationFrame(tick)
+  }
+  h5RafId = requestAnimationFrame(tick)
+}
+
+function h5StopScanner() {
+  if (h5RafId != null) {
+    cancelAnimationFrame(h5RafId)
+    h5RafId = null
+  }
+  if (h5Stream) {
+    h5Stream.getTracks().forEach((t) => t.stop())
+    h5Stream = null
+  }
+  const wrap = typeof document !== 'undefined' ? document.getElementById('h5-video-wrap') : null
+  if (wrap) wrap.innerHTML = ''
 }
 
 onUnmounted(() => {
   if (scanTimer) clearTimeout(scanTimer)
+  h5StopScanner()
 })
 
 async function onScanSuccess(qrContent: string) {
@@ -138,9 +243,21 @@ function onManualInput() {
   background: #000000;
   overflow: hidden;
 }
+/* H5 端:video 预览容器铺满底层,扫码框在上层 */
+.h5-video-wrap {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 0;
+  background: #000000;
+}
 .overlay {
   position: absolute;
   background: rgba(0, 0, 0, 0.6);
+  z-index: 2;
+  pointer-events: none;
 }
 .top-overlay {
   top: 0;
@@ -174,6 +291,7 @@ function onManualInput() {
   height: 400rpx;
   background: transparent;
   overflow: hidden;
+  z-index: 3;
 }
 .scan-corner {
   position: absolute;
@@ -183,7 +301,7 @@ function onManualInput() {
   &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
   &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
   &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
-  &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
+  &.br { bottom: 0; right: 0; border-right: none; border-top: none; }
 }
 .scan-line {
   position: absolute;
@@ -207,6 +325,7 @@ function onManualInput() {
   text-align: center;
   color: #FFFFFF;
   font-size: $font-sm;
+  z-index: 4;
 }
 .scan-btn-row {
   position: absolute;
@@ -215,6 +334,7 @@ function onManualInput() {
   right: 0;
   display: flex;
   justify-content: center;
+  z-index: 4;
   .btn {
     width: 280rpx;
     height: 80rpx;

+ 107 - 5
src/pages/qualityInspection/scan/index.vue

@@ -1,5 +1,10 @@
 <template>
   <view class="scan-page">
+    <!-- H5 端摄像头预览容器(由 JS 动态创建 video) -->
+    <!-- #ifdef H5 -->
+    <view id="h5-video-wrap" class="h5-video-wrap"></view>
+    <!-- #endif -->
+
     <!-- 顶部黑色遮罩 -->
     <view class="overlay top-overlay"></view>
     <view class="overlay bottom-overlay"></view>
@@ -16,7 +21,7 @@
     </view>
 
     <view class="scan-tip">
-      <text>请将摄像头对准品控巡检点位的二维码</text>
+      <text>{{ tipText }}</text>
     </view>
 
     <view class="scan-btn-row">
@@ -37,7 +42,10 @@ import { verifyQualityQrCode, getScanRangeMeter } from '@/api/qualityInspection'
 
 const taskId = ref<number>(0)
 const scanResult = ref<string>('')
+const tipText = ref<string>('请将摄像头对准品控巡检点位的二维码')
 let scanTimer: any = null
+let h5Stream: MediaStream | null = null
+let h5RafId: number | null = null
 
 // 缓存任务点位经纬度(进入页时从 storage 取,真实项目可从任务详情接口返回)
 let taskLng = 0
@@ -50,10 +58,6 @@ onLoad((q) => {
   startScan()
 })
 
-onMounted(() => {
-  // H5 端 uni.scanCode 不支持,改用 setTimeout 启动后用户点"手动输入"即可
-})
-
 function startScan() {
   // #ifdef APP-PLUS || MP-WEIXIN
   scanTimer = setTimeout(() => {
@@ -70,10 +74,97 @@ function startScan() {
     })
   }, 300)
   // #endif
+
+  // #ifdef H5
+  h5StartBarcodeScanner()
+  // #endif
+}
+
+// ============================== H5 端扫码实现 ==============================
+async function h5StartBarcodeScanner() {
+  const BarcodeDetectorCtor: any = (window as any).BarcodeDetector
+  if (typeof BarcodeDetectorCtor !== 'function') {
+    tipText.value = '当前浏览器不支持原生扫码,请点击下方"手动输入"'
+    return
+  }
+  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+    tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
+    return
+  }
+  try {
+    h5Stream = await navigator.mediaDevices.getUserMedia({
+      video: { facingMode: { ideal: 'environment' } },
+      audio: false
+    })
+  } catch (e: any) {
+    if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
+      tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
+    } else if (e?.name === 'NotFoundError') {
+      tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
+    } else {
+      tipText.value = '摄像头启动失败,请点击下方"手动输入"'
+    }
+    console.warn('[H5 扫码] getUserMedia 失败', e)
+    return
+  }
+  const wrap = document.getElementById('h5-video-wrap')
+  if (!wrap) return
+  wrap.innerHTML = ''
+  const video = document.createElement('video')
+  video.setAttribute('playsinline', 'true')
+  video.setAttribute('autoplay', 'true')
+  video.setAttribute('muted', 'true')
+  video.muted = true
+  video.style.cssText =
+    'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;'
+  video.srcObject = h5Stream
+  wrap.appendChild(video)
+  try {
+    await video.play()
+  } catch (e) {
+    console.warn('[H5 扫码] video.play 失败', e)
+  }
+  let detector: any
+  try {
+    detector = new BarcodeDetectorCtor({ formats: ['qr_code'] })
+  } catch (e) {
+    tipText.value = '浏览器不支持 QR 码识别,请点击下方"手动输入"'
+    return
+  }
+  const tick = async () => {
+    if (!h5Stream) return
+    try {
+      const codes = await detector.detect(video)
+      if (codes && codes.length && codes[0].rawValue) {
+        h5StopScanner()
+        scanResult.value = codes[0].rawValue
+        onScanSuccess(codes[0].rawValue)
+        return
+      }
+    } catch (e) {
+      // 单帧失败忽略
+    }
+    h5RafId = requestAnimationFrame(tick)
+  }
+  h5RafId = requestAnimationFrame(tick)
+}
+
+function h5StopScanner() {
+  if (h5RafId != null) {
+    cancelAnimationFrame(h5RafId)
+    h5RafId = null
+  }
+  if (h5Stream) {
+    h5Stream.getTracks().forEach((t) => t.stop())
+    h5Stream = null
+  }
+  const wrap = typeof document !== 'undefined' ? document.getElementById('h5-video-wrap') : null
+  if (wrap) wrap.innerHTML = ''
 }
 
 onUnmounted(() => {
   if (scanTimer) clearTimeout(scanTimer)
+  h5StopScanner()
 })
 
 async function onScanSuccess(qrContent: string) {
@@ -145,9 +236,20 @@ function onManualInput() {
   background: #000000;
   overflow: hidden;
 }
+.h5-video-wrap {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 0;
+  background: #000000;
+}
 .overlay {
   position: absolute;
   background: rgba(0, 0, 0, 0.6);
+  z-index: 2;
+  pointer-events: none;
 }
 .top-overlay {
   top: 0;