Browse Source

Add 添加VbQrScan二维码扫描组件(需要在https环境下使用)

Yue 6 months ago
parent
commit
28476ba570

+ 2 - 0
UI/VAP_V3.VUE/package.json

@@ -33,6 +33,7 @@
 		"element-plus": "^2.10.5",
 		"file-saver": "^2.0.5",
 		"jsencrypt": "^3.3.2",
+		"jsqr": "^1.4.0",
 		"min-dash": "^4.1.1",
 		"nprogress": "^0.2.0",
 		"object-path": "^0.11.8",
@@ -64,6 +65,7 @@
 		"@types/object-path": "^0.11.1",
 		"@types/prismjs": "^1.26.0",
 		"@typescript-eslint/eslint-plugin": "^6.1.0",
+		"@vitejs/plugin-basic-ssl": "^2.1.0",
 		"@vitejs/plugin-vue": "^4.2.3",
 		"@vue/eslint-config-prettier": "^7.0.0",
 		"@vue/eslint-config-typescript": "^11.0.0",

+ 425 - 0
UI/VAP_V3.VUE/src/components/qrcode/VbQrScan.vue

@@ -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>

+ 7 - 1
UI/VAP_V3.VUE/vite.config.ts

@@ -1,6 +1,8 @@
 import { defineConfig, loadEnv } from "vite"
 import { resolve } from "path"
 import createVitePlugins from "./vite/plugins"
+import * as basicSsl from "@vitejs/plugin-basic-ssl"
+
 //import { visualizer } from "rollup-plugin-visualizer"
 // https://vitejs.dev/config/
 export default defineConfig(({ mode, command }) => {
@@ -11,7 +13,9 @@ export default defineConfig(({ mode, command }) => {
 		// 例如 https://www.vber.net/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.vber.net/admin/,则设置 baseUrl 为 /admin/。
 		base: env.VITE_APP_CONTEXT_PATH,
 		plugins: [
-			...createVitePlugins(env, command === "build")
+			...createVitePlugins(env, command === "build"),
+			// @ts-ignore
+			basicSsl.default()
 			//,visualizer()
 		],
 		build: {
@@ -115,6 +119,8 @@ export default defineConfig(({ mode, command }) => {
 			// 设为 true 时若端口已被占用则会直接退出,而不是尝试下一个可用端口
 			strictPort: true,
 			open: true,
+			// 开启https
+			https: true,
 			proxy: {
 				[env.VITE_APP_BASE_API]: {
 					//target: "http://localhost:8080",