Files
hotel-biz-h5/src/views/verify/VerifyView.vue
2026-07-01 12:54:49 +08:00

323 lines
8.1 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 { 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
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
}
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 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 = detector ? await detectWithBarcodeDetector(video) : detectWithJsQr(video)
if (value) {
handleCodeValue(value)
return
}
}
} catch {
scanError.value = '扫码识别失败,请调整距离或光线后重试'
}
if (scanning.value) {
frameId = window.requestAnimationFrame(detectLoop)
}
}
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 navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: 'environment' }
}
})
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 = error instanceof Error && error.name === 'NotAllowedError'
? '未获得摄像头权限,请允许后重试'
: '无法打开摄像头,请检查浏览器权限'
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>