|
|
@@ -0,0 +1,425 @@
|
|
|
+<script setup lang="ts">
|
|
|
+// @ts-ignore
|
|
|
+import jsQR from "jsqr"
|
|
|
+import { ref, onMounted, onUnmounted } from "vue"
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ /**
|
|
|
+ * 扫描提示语
|
|
|
+ * @default "将二维码放入框内,即可自动扫描"
|
|
|
+ */
|
|
|
+ tipMessage?: string
|
|
|
+ /**
|
|
|
+ * 是否启用手电筒功能
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ enableTorch?: boolean
|
|
|
+ /**
|
|
|
+ * 是否启用连续扫描
|
|
|
+ * @default false
|
|
|
+ */
|
|
|
+ continuous?: boolean
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ tipMessage: "将二维码放入框内,即可自动扫描",
|
|
|
+ enableTorch: true,
|
|
|
+ continuous: false
|
|
|
+ }
|
|
|
+)
|
|
|
+const emits = defineEmits<{
|
|
|
+ /**
|
|
|
+ * 扫描成功时触发
|
|
|
+ * @param result 扫描结果
|
|
|
+ */
|
|
|
+ (e: "scanSuccess", result: string): void
|
|
|
+ /**
|
|
|
+ * 关闭扫码组件时触发
|
|
|
+ */
|
|
|
+ (e: "close"): void
|
|
|
+}>()
|
|
|
+
|
|
|
+const video = ref<HTMLVideoElement | null>(null)
|
|
|
+const canvas = ref<HTMLCanvasElement | null>(null)
|
|
|
+const streamRef = ref<MediaStream | null>(null)
|
|
|
+const isScanning = ref(false)
|
|
|
+const torchSupported = ref(false)
|
|
|
+const torchOn = ref(false)
|
|
|
+const scanningResult = ref("")
|
|
|
+
|
|
|
+let animationFrameId: number | null = null
|
|
|
+
|
|
|
+function init() {
|
|
|
+ startScan()
|
|
|
+}
|
|
|
+
|
|
|
+function startScan() {
|
|
|
+ if (isScanning.value) return
|
|
|
+
|
|
|
+ isScanning.value = true
|
|
|
+ const constraints: MediaStreamConstraints = {
|
|
|
+ video: {
|
|
|
+ facingMode: "environment",
|
|
|
+ width: { ideal: 1280 },
|
|
|
+ height: { ideal: 720 }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ navigator.mediaDevices
|
|
|
+ .getUserMedia(constraints)
|
|
|
+ .then((stream) => {
|
|
|
+ streamRef.value = stream
|
|
|
+
|
|
|
+ // 检查是否支持手电筒
|
|
|
+ const track = stream.getVideoTracks()[0]
|
|
|
+ if (props.enableTorch && track && track.getCapabilities) {
|
|
|
+ const capabilities: any = track.getCapabilities()
|
|
|
+ if (capabilities.torch) {
|
|
|
+ torchSupported.value = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (video.value) {
|
|
|
+ video.value.srcObject = stream
|
|
|
+ video.value.setAttribute("playsinline", "true") // iOS Safari 兼容
|
|
|
+ video.value.play()
|
|
|
+ animationFrameId = requestAnimationFrame(tick)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ console.error("摄像头访问错误:", err)
|
|
|
+ isScanning.value = false
|
|
|
+
|
|
|
+ let errorMessage = "无法访问摄像头"
|
|
|
+ if (err.name === "NotAllowedError") {
|
|
|
+ errorMessage = "用户拒绝了摄像头访问权限"
|
|
|
+ } else if (err.name === "NotFoundError") {
|
|
|
+ errorMessage = "未找到摄像头设备"
|
|
|
+ } else if (err.name === "NotReadableError") {
|
|
|
+ errorMessage = "摄像头正被其他应用占用"
|
|
|
+ } else if (err.name === "OverconstrainedError") {
|
|
|
+ errorMessage = "摄像头不支持所需的分辨率"
|
|
|
+ }
|
|
|
+
|
|
|
+ alert(errorMessage)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function toggleTorch() {
|
|
|
+ if (!torchSupported.value || !streamRef.value) return
|
|
|
+
|
|
|
+ const track = streamRef.value.getVideoTracks()[0]
|
|
|
+ if (track) {
|
|
|
+ torchOn.value = !torchOn.value
|
|
|
+ track
|
|
|
+ .applyConstraints({
|
|
|
+ advanced: [{ torch: torchOn.value } as any]
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ console.error("切换手电筒失败:", err)
|
|
|
+ torchOn.value = !torchOn.value
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function closeScanner() {
|
|
|
+ stopScan()
|
|
|
+ emits("close")
|
|
|
+}
|
|
|
+
|
|
|
+function tick() {
|
|
|
+ if (!isScanning.value) return
|
|
|
+
|
|
|
+ if (video.value && video.value.readyState === video.value.HAVE_ENOUGH_DATA) {
|
|
|
+ if (!canvas.value) return
|
|
|
+
|
|
|
+ const ctx = canvas.value.getContext("2d")
|
|
|
+ if (!ctx) return
|
|
|
+
|
|
|
+ canvas.value.width = video.value.videoWidth
|
|
|
+ canvas.value.height = video.value.videoHeight
|
|
|
+
|
|
|
+ ctx.drawImage(video.value, 0, 0, canvas.value.width, canvas.value.height)
|
|
|
+ const imageData = ctx.getImageData(0, 0, canvas.value.width, canvas.value.height)
|
|
|
+
|
|
|
+ const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
|
|
+ inversionAttempts: "dontInvert"
|
|
|
+ })
|
|
|
+
|
|
|
+ if (code) {
|
|
|
+ scanningResult.value = code.data
|
|
|
+ // 根据continuous属性决定是否继续扫描
|
|
|
+ if (!props.continuous) {
|
|
|
+ closeScanner()
|
|
|
+ }
|
|
|
+ emits("scanSuccess", code.data)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isScanning.value) {
|
|
|
+ animationFrameId = requestAnimationFrame(tick)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function stopScan() {
|
|
|
+ isScanning.value = false
|
|
|
+
|
|
|
+ if (animationFrameId) {
|
|
|
+ cancelAnimationFrame(animationFrameId)
|
|
|
+ animationFrameId = null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (streamRef.value) {
|
|
|
+ streamRef.value.getTracks().forEach((track) => {
|
|
|
+ track.stop()
|
|
|
+ })
|
|
|
+ streamRef.value = null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (video.value) {
|
|
|
+ video.value.srcObject = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(init)
|
|
|
+onUnmounted(stopScan)
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="qr-scanner">
|
|
|
+ <!-- 视频背景 -->
|
|
|
+ <video ref="video" class="scanner-video" autoplay muted playsinline></video>
|
|
|
+
|
|
|
+ <!-- 遮罩层 -->
|
|
|
+ <div class="scanner-overlay">
|
|
|
+ <!-- 顶部半透明遮罩 -->
|
|
|
+ <div class="scanner-mask top-mask"></div>
|
|
|
+
|
|
|
+ <!-- 中间扫描区域 -->
|
|
|
+ <div class="scanner-middle">
|
|
|
+ <!-- 左侧遮罩 -->
|
|
|
+ <div class="scanner-mask side-mask"></div>
|
|
|
+
|
|
|
+ <!-- 扫描框 -->
|
|
|
+ <div class="scanner-frame">
|
|
|
+ <!-- 扫描线 -->
|
|
|
+ <div class="scanner-line"></div>
|
|
|
+
|
|
|
+ <!-- 角落装饰 -->
|
|
|
+ <div class="corner top-left"></div>
|
|
|
+ <div class="corner top-right"></div>
|
|
|
+ <div class="corner bottom-left"></div>
|
|
|
+ <div class="corner bottom-right"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 右侧遮罩 -->
|
|
|
+ <div class="scanner-mask side-mask"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部半透明遮罩 -->
|
|
|
+ <div class="scanner-mask bottom-mask">
|
|
|
+ <!-- 扫描提示文字 -->
|
|
|
+ <div class="scanner-tip">{{ tipMessage }}</div>
|
|
|
+ <div
|
|
|
+ v-if="props.enableTorch && torchSupported"
|
|
|
+ class="torch-button"
|
|
|
+ :class="{ active: torchOn }"
|
|
|
+ @click="toggleTorch">
|
|
|
+ <i v-if="torchOn" class="bi bi-brightness-high-fill torch-icon"></i>
|
|
|
+ <i v-else class="bi bi-brightness-low torch-icon text-white"></i>
|
|
|
+ <span class="mt-2">{{ torchOn ? "关闭手电筒" : "打开手电筒" }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 关闭按钮 -->
|
|
|
+ <div class="close-button" @click="closeScanner">
|
|
|
+ <i class="close-icon"></i>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 隐藏的canvas用于图像处理 -->
|
|
|
+ <canvas ref="canvas" style="display: none"></canvas>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.qr-scanner {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ z-index: 2000;
|
|
|
+ background: #000;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-video {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ transform: scaleX(-1); // 镜像翻转
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-overlay {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-mask {
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+}
|
|
|
+
|
|
|
+.top-mask {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-middle {
|
|
|
+ display: flex;
|
|
|
+ height: 300px;
|
|
|
+}
|
|
|
+
|
|
|
+.side-mask {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-frame {
|
|
|
+ width: 300px;
|
|
|
+ height: 300px;
|
|
|
+ position: relative;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 2px solid rgba(0, 255, 51, 0.6);
|
|
|
+
|
|
|
+ .scanner-line {
|
|
|
+ position: absolute;
|
|
|
+ width: 100%;
|
|
|
+ height: 2px;
|
|
|
+ background: rgba(0, 255, 51, 0.8);
|
|
|
+ box-shadow: 0 0 8px rgba(0, 255, 51, 0.8);
|
|
|
+ animation: scan 2s linear infinite;
|
|
|
+ }
|
|
|
+
|
|
|
+ .corner {
|
|
|
+ position: absolute;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border: 2px solid #00ff33;
|
|
|
+
|
|
|
+ &.top-left {
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ border-right: none;
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.top-right {
|
|
|
+ top: 0;
|
|
|
+ right: 0;
|
|
|
+ border-left: none;
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.bottom-left {
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ border-right: none;
|
|
|
+ border-top: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.bottom-right {
|
|
|
+ bottom: 0;
|
|
|
+ right: 0;
|
|
|
+ border-left: none;
|
|
|
+ border-top: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.bottom-mask {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 30px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.scanner-tip {
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.torch-button {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ .torch-icon {
|
|
|
+ font-size: 30px;
|
|
|
+ }
|
|
|
+ &.active {
|
|
|
+ color: #00ff33;
|
|
|
+ .torch-icon {
|
|
|
+ color: #00ff33;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.close-button {
|
|
|
+ position: absolute;
|
|
|
+ top: 20px;
|
|
|
+ left: 20px;
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ z-index: 10;
|
|
|
+
|
|
|
+ .close-icon {
|
|
|
+ position: relative;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+
|
|
|
+ &::before,
|
|
|
+ &::after {
|
|
|
+ content: "";
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 2px;
|
|
|
+ background: #fff;
|
|
|
+ transform-origin: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ &::before {
|
|
|
+ transform: rotate(45deg);
|
|
|
+ }
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ transform: rotate(-45deg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes scan {
|
|
|
+ 0% {
|
|
|
+ top: 0;
|
|
|
+ }
|
|
|
+ 100% {
|
|
|
+ top: 100%;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|