345 lines
8.7 KiB
Vue
345 lines
8.7 KiB
Vue
<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>
|