|
|
@@ -24,8 +24,30 @@
|
|
|
<text>{{ tipText }}</text>
|
|
|
</view>
|
|
|
|
|
|
- <view class="scan-btn-row">
|
|
|
+ <!-- 操作按钮组 -->
|
|
|
+ <view class="scan-btn-row scan-btn-group">
|
|
|
<button class="btn btn-default" @tap="onManualInput">手动输入</button>
|
|
|
+ <button v-if="showPhotoScan" class="btn btn-default" @tap="onPhotoScan">拍照扫码</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 隐藏的 file input(用于微信等不支持 getUserMedia 的浏览器) -->
|
|
|
+ <!-- #ifdef H5 -->
|
|
|
+ <input
|
|
|
+ ref="fileInputRef"
|
|
|
+ type="file"
|
|
|
+ accept="image/*"
|
|
|
+ capture="environment"
|
|
|
+ style="display:none"
|
|
|
+ @change="onFileSelected"
|
|
|
+ />
|
|
|
+ <!-- #endif -->
|
|
|
+
|
|
|
+ <!-- 点击重试按钮(仅 iOS getUserMedia 因手势问题失败时显示) -->
|
|
|
+ <view v-if="scanRetry" class="scan-retry-overlay" @tap="onRetryScan">
|
|
|
+ <view class="scan-retry-btn">
|
|
|
+ <text class="scan-retry-icon">📷</text>
|
|
|
+ <text class="scan-retry-text">点击重试扫码</text>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</template>
|
|
|
@@ -45,15 +67,90 @@ import jsQR from 'jsqr'
|
|
|
const taskId = ref<number>(0)
|
|
|
const scanResult = ref<string>('')
|
|
|
const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
|
|
|
+const scanRetry = ref<boolean>(false)
|
|
|
+const showPhotoScan = ref<boolean>(false)
|
|
|
+const fileInputRef = ref<any>(null)
|
|
|
+
|
|
|
+let scanTimer: any = null
|
|
|
+let h5Stream: MediaStream | null = null
|
|
|
+let h5RafId: number | null = null
|
|
|
|
|
|
onLoad((q) => {
|
|
|
taskId.value = Number(q?.taskId || 0)
|
|
|
+ // 自动启动扫码(大部分设备直接可用)
|
|
|
+ // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
|
|
|
startScan()
|
|
|
})
|
|
|
|
|
|
-let scanTimer: any = null
|
|
|
-let h5Stream: MediaStream | null = null
|
|
|
-let h5RafId: number | null = null
|
|
|
+// H5:用户点击重试扫码(确保 getUserMedia 在用户手势上下文中调用)
|
|
|
+function onRetryScan() {
|
|
|
+ scanRetry.value = false
|
|
|
+ startScan()
|
|
|
+}
|
|
|
+
|
|
|
+// H5:拍照扫码(通过 file input 拍照,适用于微信等不支持 getUserMedia 的浏览器)
|
|
|
+function onPhotoScan() {
|
|
|
+ if (fileInputRef.value) {
|
|
|
+ fileInputRef.value.value = ''
|
|
|
+ fileInputRef.value.click()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// H5:file input 选择图片后的回调
|
|
|
+async function onFileSelected(e: any) {
|
|
|
+ const file = e?.target?.files?.[0]
|
|
|
+ if (!file) return
|
|
|
+ try {
|
|
|
+ tipText.value = '正在识别二维码…'
|
|
|
+ const img = await loadImageFromFile(file)
|
|
|
+ const code = decodeQrFromImage(img)
|
|
|
+ if (code) {
|
|
|
+ h5StopScanner()
|
|
|
+ scanResult.value = code
|
|
|
+ onScanSuccess(code)
|
|
|
+ } else {
|
|
|
+ tipText.value = '未识别到二维码,请重新拍照'
|
|
|
+ uni.showToast({ title: '未识别到二维码', icon: 'none' })
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ tipText.value = '图片识别失败,请重试'
|
|
|
+ console.warn('[拍照扫码] 识别失败', err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// H5:从 File 加载 Image 对象
|
|
|
+function loadImageFromFile(file: File): Promise<HTMLImageElement> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const url = URL.createObjectURL(file)
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => { URL.revokeObjectURL(url); resolve(img) }
|
|
|
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
|
|
|
+ img.src = url
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// H5:用 jsQR 从图片中解码二维码
|
|
|
+function decodeQrFromImage(img: HTMLImageElement): string | null {
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ const MAX = 1024
|
|
|
+ let w = img.naturalWidth
|
|
|
+ let h = img.naturalHeight
|
|
|
+ if (w > MAX || h > MAX) {
|
|
|
+ const ratio = Math.min(MAX / w, MAX / h)
|
|
|
+ w = Math.round(w * ratio)
|
|
|
+ h = Math.round(h * ratio)
|
|
|
+ }
|
|
|
+ canvas.width = w
|
|
|
+ canvas.height = h
|
|
|
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
|
+ if (!ctx) return null
|
|
|
+ ctx.drawImage(img, 0, 0, w, h)
|
|
|
+ const imageData = ctx.getImageData(0, 0, w, h)
|
|
|
+ const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
|
|
+ inversionAttempts: 'dontInvert'
|
|
|
+ })
|
|
|
+ return code?.data || null
|
|
|
+}
|
|
|
|
|
|
function startScan() {
|
|
|
// #ifdef APP-PLUS || MP-WEIXIN
|
|
|
@@ -86,7 +183,8 @@ async function h5StartBarcodeScanner() {
|
|
|
// 1. 检测摄像头权限 API
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
console.warn('[H5 扫码] 浏览器不支持 getUserMedia')
|
|
|
- tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
|
|
|
+ showPhotoScan.value = true
|
|
|
+ tipText.value = '当前浏览器不支持实时扫码,请使用拍照扫码或手动输入'
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
@@ -98,11 +196,16 @@ async function h5StartBarcodeScanner() {
|
|
|
console.log('[H5 扫码] getUserMedia 成功')
|
|
|
} catch (e: any) {
|
|
|
if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
|
|
|
- tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
|
|
|
+ // iOS Safari getUserMedia 需要用户手势,此时显示重试按钮
|
|
|
+ scanRetry.value = true
|
|
|
+ showPhotoScan.value = true
|
|
|
+ tipText.value = '点击下方按钮开始扫码'
|
|
|
} else if (e?.name === 'NotFoundError') {
|
|
|
- tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
|
|
|
+ showPhotoScan.value = true
|
|
|
+ tipText.value = '未找到可用摄像头,请使用拍照扫码或手动输入'
|
|
|
} else {
|
|
|
- tipText.value = '摄像头启动失败,请点击下方"手动输入"'
|
|
|
+ showPhotoScan.value = true
|
|
|
+ tipText.value = '摄像头启动失败,请使用拍照扫码或手动输入'
|
|
|
}
|
|
|
console.warn('[H5 扫码] getUserMedia 失败', e)
|
|
|
return
|
|
|
@@ -180,6 +283,8 @@ function h5StopScanner() {
|
|
|
onUnmounted(() => {
|
|
|
if (scanTimer) clearTimeout(scanTimer)
|
|
|
h5StopScanner()
|
|
|
+ scanRetry.value = false
|
|
|
+ showPhotoScan.value = false
|
|
|
})
|
|
|
|
|
|
async function onScanSuccess(qrContent: string) {
|
|
|
@@ -310,7 +415,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-right: none; border-top: none; }
|
|
|
+ &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
|
|
|
}
|
|
|
.scan-line {
|
|
|
position: absolute;
|
|
|
@@ -344,6 +449,11 @@ function onManualInput() {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
z-index: 4;
|
|
|
+ &.scan-btn-group {
|
|
|
+ gap: 24rpx;
|
|
|
+ padding: 0 32rpx;
|
|
|
+ .btn { flex: 1; max-width: 280rpx; }
|
|
|
+ }
|
|
|
.btn {
|
|
|
width: 280rpx;
|
|
|
height: 80rpx;
|
|
|
@@ -354,4 +464,34 @@ function onManualInput() {
|
|
|
&::after { border: none; }
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+/* 点击重试按钮(仅 iOS 手势问题失败时显示) */
|
|
|
+.scan-retry-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 10;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgba(0, 0, 0, 0.7);
|
|
|
+}
|
|
|
+.scan-retry-btn {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 240rpx;
|
|
|
+ height: 240rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ border: 2rpx solid rgba(255, 255, 255, 0.4);
|
|
|
+}
|
|
|
+.scan-retry-icon {
|
|
|
+ font-size: 72rpx;
|
|
|
+ margin-bottom: 12rpx;
|
|
|
+}
|
|
|
+.scan-retry-text {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #FFFFFF;
|
|
|
+}
|
|
|
</style>
|