| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- <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">
- <button class="btn btn-default" @tap="onManualInput">手动输入</button>
- </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 { verifyQrCode } from '@/api/inspection'
- // jsQR:纯 JS QR 识别库,作为 BarcodeDetector 不可用时的 fallback(iOS Safari < 17 / 老 WebView)
- import jsQR from 'jsqr'
- const taskId = ref<number>(0)
- const scanResult = ref<string>('')
- const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
- onLoad((q) => {
- taskId.value = Number(q?.taskId || 0)
- startScan()
- })
- let scanTimer: any = null
- let h5Stream: MediaStream | null = null
- let h5RafId: number | null = null
- function startScan() {
- // #ifdef APP-PLUS || MP-WEIXIN
- // APP/小程序:调起原生扫码(uni.scanCode 会自动开摄像头 + 弹原生 UI)
- scanTimer = setTimeout(() => {
- uni.scanCode({
- onlyFromCamera: true,
- scanType: ['qrCode'],
- success: (res) => {
- scanResult.value = res.result
- onScanSuccess(res.result)
- },
- fail: () => {
- // 用户取消
- }
- })
- }, 300)
- // #endif
- // #ifdef H5
- // H5 端:用浏览器原生 BarcodeDetector + <video> 预览
- // 浏览器不支持(iOS Safari < 16.4)→ 提示降级到"手动输入"
- h5StartBarcodeScanner()
- // #endif
- }
- // ============================== H5 端扫码实现 ==============================
- async function h5StartBarcodeScanner() {
- // 1. 检测摄像头权限 API
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
- return
- }
- try {
- // 2. 申请摄像头(优先后置)
- 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
- }
- // 3. 创建 <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)
- }
- // 4. 选 QR 识别方案:
- // 优先 BarcodeDetector(Chrome/Edge/iOS 17+ 性能好),
- // 不可用时 fallback jsQR(纯 JS,兼容老浏览器但稍慢)
- const BarcodeDetectorCtor: any = (window as any).BarcodeDetector
- if (typeof BarcodeDetectorCtor === 'function') {
- let detector: any
- try {
- detector = new BarcodeDetectorCtor({ formats: ['qr_code'] })
- } catch (e) {
- // ignore
- }
- if (detector) {
- 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)
- return
- }
- }
- // 5. Fallback:jsQR(用 canvas 抽帧识别)
- tipText.value = '正在用 jsQR 识别中…'
- 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)
- 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
- }
- }
- 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) {
- uni.showLoading({ title: '校验中…' })
- try {
- // 1. 校验二维码与任务绑定关系
- // 走统一 request 后:data 即后端 R.data(这里后端直接返回 boolean)
- const valid = await verifyQrCode(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()
- // 任务点位经纬度(实际项目从任务详情中获取)
- const taskLng = uni.getStorageSync(`task_lng_${taskId.value}`) || 0
- const taskLat = uni.getStorageSync(`task_lat_${taskId.value}`) || 0
- if (taskLng && taskLat) {
- const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
- const limitMeter = uni.getStorageSync('scan_limit_meter') || 100 // 系统配置
- if (dist > limitMeter) {
- uni.hideLoading()
- uni.showToast({
- title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
- icon: 'none',
- duration: 3000
- })
- return
- }
- }
- uni.hideLoading()
- // 3. 校验通过:跳转到打卡页
- uni.redirectTo({
- url: `/pages/inspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}`
- })
- } 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>
- .scan-page {
- position: relative;
- width: 100%;
- height: 100vh;
- 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;
- 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;
- z-index: 3;
- }
- .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-right: 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;
- z-index: 4;
- }
- .scan-btn-row {
- position: absolute;
- bottom: 80rpx;
- left: 0;
- right: 0;
- display: flex;
- justify-content: center;
- z-index: 4;
- .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; }
- }
- }
- </style>
|