Files
hotel-biz-h5/src/views/verify/VerifyView.vue

345 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { QrCode, X } from 'lucide-vue-next'
import jsQR from 'jsqr'
import {
cameraConstraintCandidates,
getCameraAccessErrorMessage,
shouldTryNextCameraConstraint
} from '@/utils/cameraAccess'
import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode'
const router = useRouter()
const videoRef = ref<HTMLVideoElement | null>(null)
const loading = ref(false)
const scanning = ref(false)
const scanError = ref('')
let stream: MediaStream | null = null
let detector: { detect: (source: HTMLVideoElement) => Promise<Array<{ rawValue: string }>> } | null = null
let canvas: HTMLCanvasElement | null = null
let canvasContext: CanvasRenderingContext2D | null = null
let frameId = 0
let lastDecodeAt = 0
let lastScanHintAt = 0
const getBarcodeDetector = () => {
return (window as unknown as {
BarcodeDetector?: new (options: { formats: string[] }) => {
detect: (source: HTMLVideoElement) => Promise<Array<{ rawValue: string }>>
}
}).BarcodeDetector
}
const stopScan = () => {
scanning.value = false
if (frameId) {
window.cancelAnimationFrame(frameId)
frameId = 0
}
if (stream) {
stream.getTracks().forEach((track) => track.stop())
stream = null
}
if (videoRef.value) {
videoRef.value.srcObject = null
}
detector = null
lastDecodeAt = 0
lastScanHintAt = 0
}
const goConfirm = (payload: WriteOffCodePayload) => {
router.push({
path: '/verify/confirm',
query: {
orderId: payload.orderId,
source: 'scan',
...(payload.packageName ? { packageName: payload.packageName } : {})
}
})
}
const handleCodeValue = (rawValue: string) => {
const payload = parseWriteOffCode(rawValue)
stopScan()
goConfirm(payload)
}
const detectWithBarcodeDetector = async (video: HTMLVideoElement) => {
if (!detector) return ''
const results = await detector.detect(video)
return results[0]?.rawValue || ''
}
const detectWithJsQr = (video: HTMLVideoElement) => {
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) return ''
if (!canvas) {
canvas = document.createElement('canvas')
}
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
canvasContext = canvas.getContext('2d', { willReadFrequently: true })
}
if (!canvasContext) return ''
canvasContext.drawImage(video, 0, 0, width, height)
const imageData = canvasContext.getImageData(0, 0, width, height)
return jsQR(imageData.data, width, height, { inversionAttempts: 'dontInvert' })?.data || ''
}
const detectQrValue = async (video: HTMLVideoElement) => {
const detectorValue = detector ? await detectWithBarcodeDetector(video).catch(() => '') : ''
return detectorValue || detectWithJsQr(video)
}
const detectLoop = async () => {
if (!scanning.value || !videoRef.value) return
const video = videoRef.value
try {
const now = Date.now()
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) {
lastDecodeAt = now
const value = await detectQrValue(video)
if (value) {
handleCodeValue(value)
return
}
if (!scanError.value && now - lastScanHintAt > 3000) {
lastScanHintAt = now
scanError.value = '暂未识别到二维码,请将二维码完整放入取景框'
}
}
} catch {
scanError.value = '扫码识别失败,请调整距离或切换角度后重试'
}
if (scanning.value) {
frameId = window.requestAnimationFrame(detectLoop)
}
}
const openCameraStream = async () => {
let lastError: unknown = null
for (const constraints of cameraConstraintCandidates) {
try {
return await navigator.mediaDevices.getUserMedia(constraints)
} catch (error) {
lastError = error
if (!shouldTryNextCameraConstraint(error)) break
}
}
throw lastError
}
const startScan = async () => {
if (scanning.value || loading.value) return
const BarcodeDetector = getBarcodeDetector()
if (!navigator.mediaDevices?.getUserMedia) {
scanError.value = '当前浏览器无法打开摄像头'
showToast('当前浏览器无法打开摄像头')
return
}
loading.value = true
scanError.value = ''
try {
detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null
stream = await openCameraStream()
scanning.value = true
await nextTick()
if (!videoRef.value) return
videoRef.value.srcObject = stream
await videoRef.value.play()
frameId = window.requestAnimationFrame(detectLoop)
} catch (error) {
stopScan()
scanError.value = getCameraAccessErrorMessage(error)
showToast(scanError.value)
} finally {
loading.value = false
}
}
onBeforeUnmount(stopScan)
</script>
<template>
<section class="page">
<header class="page-header">
<p class="page-kicker">商品核销</p>
<h1 class="page-title">扫码核销</h1>
<p class="page-subtitle">扫描客人二维码核对订单信息后再确认核销</p>
</header>
<section class="scan-panel panel">
<div v-if="scanning" class="scanner-live">
<video ref="videoRef" muted playsinline />
<div class="scan-frame" />
<button class="stop-button" type="button" aria-label="停止扫码" @click="stopScan">
<X :size="18" />
</button>
</div>
<button v-else class="scan-button" type="button" :disabled="loading" @click="startScan">
<QrCode :size="54" />
<strong>{{ loading ? '正在打开摄像头' : '点击进行扫码' }}</strong>
<span>请将客人的核销二维码置于取景框内</span>
</button>
<p v-if="scanError" class="scan-error">{{ scanError }}</p>
</section>
<section class="scan-tips">
<div>
<strong>核销前确认</strong>
<span>扫码后仍需核对订单套餐和可核销数量</span>
</div>
<div>
<strong>识别不成功</strong>
<span>调整距离光线或请客人提高屏幕亮度</span>
</div>
</section>
</section>
</template>
<style scoped>
.scan-panel {
padding: 16px;
background:
radial-gradient(circle at 50% 0, rgba(19, 144, 111, 0.16), transparent 190px),
linear-gradient(135deg, rgba(15, 139, 114, 0.1), rgba(255, 247, 223, 0.78)),
var(--surface);
}
.scanner-live {
position: relative;
overflow: hidden;
min-height: 260px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--panel-radius);
background: #111827;
}
.scanner-live video {
display: block;
width: 100%;
height: 260px;
object-fit: cover;
}
.scan-frame {
position: absolute;
inset: 50% auto auto 50%;
width: min(68vw, 230px);
height: min(68vw, 230px);
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 12px;
box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.22);
transform: translate(-50%, -50%);
}
.scan-frame::after {
position: absolute;
top: 50%;
right: 14px;
left: 14px;
height: 2px;
background: var(--primary);
box-shadow: 0 0 16px rgba(15, 139, 114, 0.9);
content: '';
}
.stop-button {
position: absolute;
top: 10px;
right: 10px;
display: grid;
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: rgba(17, 24, 39, 0.68);
color: #fff;
place-items: center;
}
.scan-button {
display: grid;
width: 100%;
min-height: 174px;
padding: 20px 16px;
border: 1px dashed rgba(15, 139, 114, 0.5);
border-radius: var(--panel-radius);
background: rgba(255, 255, 255, 0.74);
color: var(--primary-deep);
gap: 8px;
place-items: center;
transition:
border-color var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.scan-button:active {
border-color: var(--primary);
background: rgba(255, 255, 255, 0.9);
transform: scale(0.99);
}
.scan-button strong {
color: var(--text-strong);
font-size: 17px;
font-weight: 750;
}
.scan-button span {
max-width: 210px;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
.scan-button:disabled {
opacity: 0.72;
}
.scan-error {
margin: 10px 0 0;
color: var(--rose);
font-size: 12px;
line-height: 1.5;
}
.scan-tips {
display: grid;
gap: 10px;
margin-top: 14px;
}
.scan-tips > div {
display: grid;
gap: 4px;
padding: 12px 13px;
border: 1px solid rgba(202, 214, 208, 0.78);
border-radius: var(--panel-radius);
background: rgba(255, 255, 255, 0.62);
}
.scan-tips strong {
color: var(--text-strong);
font-size: 14px;
}
.scan-tips span {
color: var(--text-muted);
font-size: 13px;
line-height: 1.5;
}
</style>