| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- <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>
- <view class="overlay left-overlay"></view>
- <view class="overlay right-overlay"></view>
- <!-- 扫码框 -->
- <view class="scan-area">
- <view class="scan-corner tl"></view>
- <view class="scan-corner tr"></view>
- <view class="scan-corner bl"></view>
- <view class="scan-corner br"></view>
- <view class="scan-line"></view>
- </view>
- <view class="scan-tip">
- <text>{{ tipText }}</text>
- </view>
- <!-- 操作按钮组 -->
- <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>
- <script setup lang="ts">
- import { ref, onUnmounted } from 'vue'
- import { onLoad } from '@dcloudio/uni-app'
- import {
- calculateDistance,
- getCurrentLocation,
- ensureLocationAuth
- } from '@/utils/location'
- import { verifyQualityQrCode, getScanRangeMeter } from '@/api/qualityInspection'
- // jsQR:纯 JS QR 识别库,作为 BarcodeDetector 不可用时的 fallback
- 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
- // 缓存任务点位经纬度(进入页时从 storage 取,真实项目可从任务详情接口返回)
- let taskLng = 0
- let taskLat = 0
- // 每日多时段任务的当前时间段(从列表传入)
- const slot = ref<string>('')
- onLoad((q) => {
- taskId.value = Number(q?.taskId || 0)
- slot.value = String(q?.slot || '')
- taskLng = Number(uni.getStorageSync(`qtask_lng_${taskId.value}`) || 0)
- taskLat = Number(uni.getStorageSync(`qtask_lat_${taskId.value}`) || 0)
- // 自动启动扫码(大部分设备直接可用)
- // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
- 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
- }
- // H5:用户点击重试扫码(确保 getUserMedia 在用户手势上下文中调用)
- function onRetryScan() {
- scanRetry.value = false
- startScan()
- }
- function startScan() {
- // #ifdef APP-PLUS || MP-WEIXIN
- scanTimer = setTimeout(() => {
- uni.scanCode({
- onlyFromCamera: true,
- scanType: ['qrCode'],
- success: (res) => {
- scanResult.value = res.result
- onScanSuccess(res.result)
- },
- fail: () => {
- // 扫码失败:用户取消
- }
- })
- }, 300)
- // #endif
- // #ifdef H5
- h5StartBarcodeScanner()
- // #endif
- }
- // ============================== H5 端扫码实现 ==============================
- async function h5StartBarcodeScanner() {
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- // 微信 X5 内核等不支持 getUserMedia,提供拍照扫码与手动输入
- showPhotoScan.value = true
- 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') {
- // iOS Safari getUserMedia 需要用户手势,此时显示重试按钮
- scanRetry.value = true
- showPhotoScan.value = true
- tipText.value = '点击下方按钮开始扫码'
- } else if (e?.name === 'NotFoundError') {
- showPhotoScan.value = true
- tipText.value = '未找到可用摄像头,请使用拍照扫码或手动输入'
- } else {
- showPhotoScan.value = true
- 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)
- }
- // 直接走 jsQR(纯 JS,iOS Safari 11+ 全支持)
- // 弃用 BarcodeDetector:iOS Safari 16.4 早期版本有 bug,实际识别率差
- tipText.value = '正在识别中…'
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d', { willReadFrequently: true })
- if (!ctx) {
- tipText.value = '当前浏览器不支持 QR 码识别,请点击下方"手动输入"'
- return
- }
- const tick = () => {
- if (!h5Stream) return
- if (video.readyState === video.HAVE_ENOUGH_DATA) {
- canvas.width = video.videoWidth
- canvas.height = video.videoHeight
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
- try {
- 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) {
- h5StopScanner()
- scanResult.value = code.data
- onScanSuccess(code.data)
- return
- }
- } catch (e) {
- // ignore
- }
- }
- 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()
- scanRetry.value = false
- })
- async function onScanSuccess(qrContent: string) {
- uni.showLoading({ title: '校验中…' })
- try {
- // 1. 校验二维码与任务绑定关系
- const valid = await verifyQualityQrCode(taskId.value, qrContent)
- if (!valid) {
- uni.hideLoading()
- uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
- return
- }
- // 2. 校验位置
- const auth = await ensureLocationAuth()
- if (!auth) {
- uni.hideLoading()
- return
- }
- const loc = await getCurrentLocation()
- // 任务点位经纬度(若未设置则跳过距离校验,仅获取位置坐标带到打卡页)
- if (taskLng && taskLat) {
- const limitMeter = await getScanRangeMeter() // 系统配置
- const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
- if (dist > limitMeter) {
- uni.hideLoading()
- uni.showToast({
- title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
- icon: 'none',
- duration: 3000
- })
- return
- }
- }
- uni.hideLoading()
- // 3. 校验通过:跳转到品控打卡页
- // 每日多时段任务:带上当前 slot
- const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
- uni.redirectTo({
- url: `/pages/qualityInspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}${slotQuery}`
- })
- } catch (e) {
- uni.hideLoading()
- uni.showToast({ title: '校验失败', icon: 'none' })
- }
- }
- function onManualInput() {
- uni.showModal({
- title: '手动输入任务码',
- editable: true,
- placeholderText: '请输入任务编号或二维码内容',
- success: (m) => {
- if (m.confirm && m.content) {
- onScanSuccess(m.content)
- }
- }
- })
- }
- </script>
- <style lang="scss" scoped>
- @import "@/static/styles/variables.scss";
- .scan-page {
- position: relative;
- width: 100%;
- height: 100vh;
- 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;
- left: 0;
- right: 0;
- height: calc(50vh - 200rpx);
- }
- .bottom-overlay {
- bottom: 0;
- left: 0;
- right: 0;
- height: calc(50vh - 200rpx);
- }
- .left-overlay {
- top: calc(50vh - 200rpx);
- left: 0;
- width: calc(50vw - 200rpx);
- height: 400rpx;
- }
- .right-overlay {
- top: calc(50vh - 200rpx);
- right: 0;
- width: calc(50vw - 200rpx);
- height: 400rpx;
- }
- .scan-area {
- position: absolute;
- top: calc(50vh - 200rpx);
- left: calc(50vw - 200rpx);
- width: 400rpx;
- height: 400rpx;
- background: transparent;
- overflow: hidden;
- }
- .scan-corner {
- position: absolute;
- width: 40rpx;
- height: 40rpx;
- border: 4rpx solid $primary;
- &.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; }
- }
- .scan-line {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 4rpx;
- background: linear-gradient(to right, transparent, $primary, transparent);
- animation: scan 2s linear infinite;
- }
- @keyframes scan {
- 0% { transform: translateY(0); }
- 50% { transform: translateY(400rpx); }
- 100% { transform: translateY(0); }
- }
- .scan-tip {
- position: absolute;
- top: calc(50vh + 240rpx);
- left: 0;
- right: 0;
- text-align: center;
- color: #FFFFFF;
- font-size: $font-sm;
- }
- .scan-btn-row {
- position: absolute;
- bottom: 80rpx;
- left: 0;
- right: 0;
- display: flex;
- justify-content: center;
- &.scan-btn-group {
- gap: 24rpx;
- padding: 0 32rpx;
- .btn { flex: 1; max-width: 280rpx; }
- }
- .btn {
- width: 280rpx;
- height: 80rpx;
- background: rgba(255, 255, 255, 0.2);
- color: #FFFFFF;
- border: 1rpx solid rgba(255, 255, 255, 0.4);
- border-radius: 80rpx;
- &::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>
|