|
|
@@ -0,0 +1,219 @@
|
|
|
+<template>
|
|
|
+ <van-popup
|
|
|
+ v-model:show="visible"
|
|
|
+ class="browser-scanner-popup"
|
|
|
+ position="bottom"
|
|
|
+ closeable
|
|
|
+ @opened="startScanner"
|
|
|
+ @closed="stopScanner"
|
|
|
+ >
|
|
|
+ <div class="scanner-header">扫码上机</div>
|
|
|
+ <div class="scanner-content">
|
|
|
+ <div v-if="errorMessage" class="scanner-error">
|
|
|
+ <van-icon name="warning-o" size="36" />
|
|
|
+ <div>{{ errorMessage }}</div>
|
|
|
+ <van-button type="primary" size="small" @click="startScanner">重试</van-button>
|
|
|
+ </div>
|
|
|
+ <div v-else class="scanner-preview">
|
|
|
+ <video ref="videoRef" class="scanner-video" muted playsinline></video>
|
|
|
+ <div class="scanner-frame" aria-hidden="true"></div>
|
|
|
+ <div class="scanner-status">正在识别二维码</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </van-popup>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
|
|
+import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ show: boolean
|
|
|
+}>()
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ (event: 'update:show', value: boolean): void
|
|
|
+ (event: 'scan', value: string): void
|
|
|
+}>()
|
|
|
+
|
|
|
+const visible = computed({
|
|
|
+ get: () => props.show,
|
|
|
+ set: (value: boolean) => emit('update:show', value)
|
|
|
+})
|
|
|
+
|
|
|
+const videoRef = ref<HTMLVideoElement>()
|
|
|
+const errorMessage = ref('')
|
|
|
+let controls: IScannerControls | undefined
|
|
|
+let scanned = false
|
|
|
+let scannerSession = 0
|
|
|
+
|
|
|
+const stopScanner = () => {
|
|
|
+ scannerSession += 1
|
|
|
+ controls?.stop()
|
|
|
+ controls = undefined
|
|
|
+
|
|
|
+ const stream = videoRef.value?.srcObject
|
|
|
+ if (stream instanceof MediaStream) {
|
|
|
+ stream.getTracks().forEach((track) => track.stop())
|
|
|
+ videoRef.value!.srcObject = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getCameraErrorMessage = (error: unknown) => {
|
|
|
+ if (!window.isSecureContext) {
|
|
|
+ return '浏览器摄像头需要通过 HTTPS 访问'
|
|
|
+ }
|
|
|
+
|
|
|
+ const errorName = error instanceof DOMException ? error.name : ''
|
|
|
+ if (errorName === 'NotAllowedError' || errorName === 'SecurityError') {
|
|
|
+ return '摄像头权限未开启,请在浏览器设置中允许访问'
|
|
|
+ }
|
|
|
+ if (errorName === 'NotFoundError' || errorName === 'OverconstrainedError') {
|
|
|
+ return '未找到可用的摄像头'
|
|
|
+ }
|
|
|
+ if (errorName === 'NotReadableError' || errorName === 'AbortError') {
|
|
|
+ return '摄像头正被其他应用占用'
|
|
|
+ }
|
|
|
+ return '摄像头启动失败,请重试'
|
|
|
+}
|
|
|
+
|
|
|
+const startScanner = async () => {
|
|
|
+ stopScanner()
|
|
|
+ const currentSession = scannerSession
|
|
|
+ errorMessage.value = ''
|
|
|
+ scanned = false
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+
|
|
|
+ if (currentSession !== scannerSession || !visible.value) return
|
|
|
+
|
|
|
+ if (!window.isSecureContext) {
|
|
|
+ errorMessage.value = getCameraErrorMessage(undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!navigator.mediaDevices?.getUserMedia) {
|
|
|
+ errorMessage.value = '当前浏览器不支持摄像头扫码'
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!videoRef.value) {
|
|
|
+ errorMessage.value = '扫码视图初始化失败,请重试'
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = new BrowserMultiFormatReader()
|
|
|
+ try {
|
|
|
+ const scannerControls = await reader.decodeFromConstraints(
|
|
|
+ {
|
|
|
+ audio: false,
|
|
|
+ video: {
|
|
|
+ facingMode: { ideal: 'environment' },
|
|
|
+ width: { ideal: 1280 },
|
|
|
+ height: { ideal: 720 }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ videoRef.value,
|
|
|
+ (result, _error, scannerControls) => {
|
|
|
+ if (currentSession !== scannerSession || !visible.value) {
|
|
|
+ scannerControls.stop()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!result || scanned) return
|
|
|
+
|
|
|
+ scanned = true
|
|
|
+ scannerControls.stop()
|
|
|
+ emit('scan', result.getText())
|
|
|
+ visible.value = false
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if (currentSession !== scannerSession || !visible.value) {
|
|
|
+ scannerControls.stop()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ controls = scannerControls
|
|
|
+ } catch (error) {
|
|
|
+ stopScanner()
|
|
|
+ errorMessage.value = getCameraErrorMessage(error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onBeforeUnmount(stopScanner)
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.browser-scanner-popup {
|
|
|
+ width: 100vw;
|
|
|
+ height: 100vh;
|
|
|
+ height: 100dvh;
|
|
|
+ background: #101214;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.browser-scanner-popup .van-popup__close-icon) {
|
|
|
+ top: calc(16px + env(safe-area-inset-top));
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-header {
|
|
|
+ height: calc(52px + env(safe-area-inset-top));
|
|
|
+ padding: env(safe-area-inset-top) 52px 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-content {
|
|
|
+ height: calc(100% - 52px - env(safe-area-inset-top));
|
|
|
+ padding-bottom: env(safe-area-inset-bottom);
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-preview {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-video {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-frame {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ width: min(64vw, 280px);
|
|
|
+ aspect-ratio: 1;
|
|
|
+ transform: translate(-50%, -56%);
|
|
|
+ border: 2px solid rgba(255, 255, 255, 0.9);
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.38);
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-status {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 28px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-error {
|
|
|
+ height: 100%;
|
|
|
+ padding: 24px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 18px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+</style>
|