Browse Source

集成jsQR扫码功能

yanhz 4 hours ago
parent
commit
279825eab3

+ 5 - 0
package-lock.json

@@ -1157,6 +1157,11 @@
 			"integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
 			"dev": true
 		},
+		"jsqr": {
+			"version": "1.4.0",
+			"resolved": "https://registry.npmmirror.com/jsqr/-/jsqr-1.4.0.tgz",
+			"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
+		},
 		"lilconfig": {
 			"version": "3.1.3",
 			"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",

+ 1 - 0
package.json

@@ -6,6 +6,7 @@
 	"dependencies": {
 		"async-validator": "^4.0.2",
 		"dayjs": "^1.10.6",
+		"jsqr": "^1.4.0",
 		"vant": "^4.9.22"
 	},
 	"devDependencies": {

+ 8 - 0
pages.json

@@ -47,6 +47,14 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/index/input-method-select",
+      "style": {
+        "navigationBarTitleText": "选择录入方式",
+        "enablePullDownRefresh": false,
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/index/vision-detail",
       "style": {

+ 36 - 0
pages/index/eye-examine-index.vue

@@ -15,6 +15,11 @@
 				</view>
 			</view>
 			
+			<view class="switch-btn" @click="switchInputMethod">
+				<van-icon name="exchange" size="20" color="#fff" />
+				<text class="switch-text">切换录入方式</text>
+			</view>
+			
 			<view class="tw-px-[30rpx] tw-mt-[40rpx] tw-pb-[100rpx]">
 				<view class="tw-bg-white tw-rounded-[20rpx] tw-overflow-hidden" style="height: calc(100vh - 400rpx);">
 					<view class="tw-flex tw-h-full">
@@ -130,9 +135,40 @@ const selectClass = (cls) => {
 		url: `/pages/index/student-list?${Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')}`
 	})
 }
+
+const switchInputMethod = () => {
+	uni.navigateTo({
+		url: '/pages/index/input-method-select'
+	})
+}
 </script>
 
 <style lang="scss" scoped>
+.switch-btn {
+	position: fixed;
+	right: 30rpx;
+	bottom: 10%;
+	width: 160rpx;
+	height: 160rpx;
+	border-radius: 50%;
+	background: #0063F5;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	box-shadow: 0 4rpx 20rpx rgba(0, 99, 245, 0.3);
+	z-index: 999;
+}
+
+.switch-text {
+	font-size: 22rpx;
+	font-weight: bold;
+	color: #fff;
+	margin-top: 8rpx;
+	text-align: center;
+	line-height: 1.2;
+}
+
 .grade-list {
 	width: 200rpx;
 	background: #f5f5f5;

+ 1 - 1
pages/index/index.vue

@@ -96,7 +96,7 @@ const handleEnter = async () => {
 	
 	uni.setStorageSync(STORAGE_KEY, JSON.stringify(formData.value))
 	uni.navigateTo({
-		url: '/pages/index/eye-examine-index'
+		url: '/pages/index/input-method-select'
 	})
 }
 </script>

+ 219 - 0
pages/index/input-method-select.vue

@@ -0,0 +1,219 @@
+<template>
+	<view class="wrap pr">
+		<view class="w-full pa tw-top-0 tw-left-0 tw-z-0">
+			<image class="w-full" :src="config.ossPathPerfixs + '/index-bg.png'" mode="widthFix"></image>
+		</view>
+		<view class="w-full pr tw-z-10">
+			<view class="tw-p-[30rpx]">
+				<view class="tw-flex tw-items-center tw-justify-start tw-mt-[60rpx]">
+					<fs-avatar size="100rpx" src="/static/images/tool/logo.png"></fs-avatar>
+					<text class="tw-text-[#fff] tw-text-[26rpx] tw-ml-[4rpx]"
+						style="letter-spacing: 1rpx;">太原市中小学学生卫生保健所</text>
+				</view>
+				<view class="tw-mt-[20rpx] tw-text-[#fff] tw-text-[32rpx] tw-font-bold">
+					学校:{{ schoolName }}
+				</view>
+			</view>
+			
+			<view class="tw-px-[30rpx] tw-mt-[40rpx] tw-pb-[100rpx]">
+				<view class="type-list">
+					<view class="type-card" @click="handleManualInput">
+						<view class="type-name">手工录入</view>
+						<van-icon name="arrow" />
+					</view>
+					<view class="type-card" @click="handleScanInput">
+						<view class="type-name">扫码录入</view>
+						<van-icon name="arrow" />
+					</view>
+				</view>
+			</view>
+		</view>
+		
+		<van-popup v-model:show="showScanner" position="center" :style="{ width: '90%', height: '80%' }">
+			<view class="scanner-popup">
+				<view class="scanner-header">
+					<text>扫描二维码</text>
+					<van-icon name="cross" @click="closeScanner" />
+				</view>
+				<view class="scanner-body">
+					<video ref="videoRef" class="scanner-video" autoplay playsinline></video>
+					<canvas ref="canvasRef" style="display: none;"></canvas>
+				</view>
+			</view>
+		</van-popup>
+	</view>
+</template>
+
+<script setup>
+import config from '@/utils/config'
+import { getSchoolInfo } from '@/services/common'
+import jsQR from 'jsqr'
+
+const STORAGE_KEY = 'eye_examine_user_info'
+const schoolName = ref('')
+const showScanner = ref(false)
+const videoRef = ref(null)
+const canvasRef = ref(null)
+let stream = null
+let animationId = null
+
+onLoad(async () => {
+	const savedData = uni.getStorageSync(STORAGE_KEY)
+	if (savedData) {
+		const userInfo = JSON.parse(savedData)
+		const schoolId = userInfo.schoolId || ''
+		
+		if (schoolId) {
+			try {
+				const res = await getSchoolInfo(schoolId)
+				if (res && res.code === 200) {
+					schoolName.value = res.data.name || ''
+				}
+			} catch (error) {
+				console.error('获取学校信息失败', error)
+			}
+		}
+	}
+})
+
+const handleManualInput = () => {
+	uni.navigateTo({
+		url: '/pages/index/eye-examine-index'
+	})
+}
+
+const handleScanInput = async () => {
+	showScanner.value = true
+	await nextTick()
+	startCamera()
+}
+
+const startCamera = async () => {
+	try {
+		stream = await navigator.mediaDevices.getUserMedia({ 
+			video: { facingMode: 'environment' } 
+		})
+		const video = videoRef.value
+		if (video) {
+			video.srcObject = stream
+			video.play()
+			scanQRCode()
+		}
+	} catch (error) {
+		console.error('摄像头启动失败', error)
+		uni.showToast({
+			title: '摄像头启动失败',
+			icon: 'none'
+		})
+	}
+}
+
+const scanQRCode = () => {
+	const video = videoRef.value
+	const canvas = canvasRef.value
+	
+	if (!video || !canvas) return
+	
+	const ctx = canvas.getContext('2d')
+	
+	const scan = () => {
+		if (video.readyState === video.HAVE_ENOUGH_DATA) {
+			canvas.width = video.videoWidth
+			canvas.height = video.videoHeight
+			ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
+			
+			const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
+			const code = jsQR(imageData.data, imageData.width, imageData.height)
+			
+			if (code) {
+				onDecode(code.data)
+				return
+			}
+		}
+		animationId = requestAnimationFrame(scan)
+	}
+	
+	scan()
+}
+
+const onDecode = (result) => {
+	closeScanner()
+	console.log('扫码结果:', result)
+}
+
+const closeScanner = () => {
+	if (animationId) {
+		cancelAnimationFrame(animationId)
+		animationId = null
+	}
+	if (stream) {
+		stream.getTracks().forEach(track => track.stop())
+		stream = null
+	}
+	showScanner.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.type-list {
+	display: flex;
+	flex-direction: column;
+	gap: 20rpx;
+}
+
+.type-card {
+	background: #fff;
+	border-radius: 20rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 50rpx 30rpx;
+	box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
+	position: relative;
+}
+
+.type-name {
+	font-size: 32rpx;
+	font-weight: bold;
+	color: #333;
+	flex: 1;
+	text-align: center;
+}
+
+.type-card :deep(.van-icon) {
+	position: absolute;
+	right: 30rpx;
+}
+
+.scanner-popup {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+	background: #000;
+}
+
+.scanner-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 20rpx;
+	background: #fff;
+}
+
+.scanner-body {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	overflow: hidden;
+}
+
+.scanner-video {
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+}
+
+
+</style>

+ 123 - 0
pages/index/vision-input.vue

@@ -49,14 +49,29 @@
 		<view class="footer">
 			<view class="footer-btns">
 				<van-button plain type="primary" round size="large" @click="onBack" icon="replay">返回</van-button>
+				<van-button type="warning" round size="large" @click="handleScan" icon="scan">扫一扫</van-button>
 				<van-button type="primary" round size="large" icon="success" @click="handleSave">保存</van-button>
 			</view>
 		</view>
+		
+		<van-popup v-model:show="showScanner" position="center" :style="{ width: '90%', height: '80%' }">
+			<view class="scanner-popup">
+				<view class="scanner-header">
+					<text>扫描二维码</text>
+					<van-icon name="cross" @click="closeScanner" />
+				</view>
+				<view class="scanner-body">
+					<video ref="videoRef" class="scanner-video" autoplay playsinline></video>
+					<canvas ref="canvasRef" style="display: none;"></canvas>
+				</view>
+			</view>
+		</van-popup>
 	</view>
 </template>
 
 <script setup>
 import { inputVisionData, inputRefractionData } from '@/services/common'
+import jsQR from 'jsqr'
 
 const STORAGE_KEY = 'eye_examine_user_info'
 const userInfo = ref({})
@@ -64,6 +79,11 @@ const studentInfo = ref({})
 const inputType = ref('')
 const visionDataId = ref('')
 const schoolName = ref('')
+const showScanner = ref(false)
+const videoRef = ref(null)
+const canvasRef = ref(null)
+let stream = null
+let animationId = null
 
 const formData = ref({
 	leftVision: '',
@@ -99,6 +119,77 @@ const onBack = () => {
 	})
 }
 
+const handleScan = async () => {
+	showScanner.value = true
+	await nextTick()
+	startCamera()
+}
+
+const startCamera = async () => {
+	try {
+		stream = await navigator.mediaDevices.getUserMedia({ 
+			video: { facingMode: 'environment' } 
+		})
+		const video = videoRef.value
+		if (video) {
+			video.srcObject = stream
+			video.play()
+			scanQRCode()
+		}
+	} catch (error) {
+		console.error('摄像头启动失败', error)
+		uni.showToast({
+			title: '摄像头启动失败',
+			icon: 'none'
+		})
+	}
+}
+
+const scanQRCode = () => {
+	const video = videoRef.value
+	const canvas = canvasRef.value
+	
+	if (!video || !canvas) return
+	
+	const ctx = canvas.getContext('2d')
+	
+	const scan = () => {
+		if (video.readyState === video.HAVE_ENOUGH_DATA) {
+			canvas.width = video.videoWidth
+			canvas.height = video.videoHeight
+			ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
+			
+			const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
+			const code = jsQR(imageData.data, imageData.width, imageData.height)
+			
+			if (code) {
+				onDecode(code.data)
+				return
+			}
+		}
+		animationId = requestAnimationFrame(scan)
+	}
+	
+	scan()
+}
+
+const onDecode = (result) => {
+	closeScanner()
+	console.log('扫码结果:', result)
+}
+
+const closeScanner = () => {
+	if (animationId) {
+		cancelAnimationFrame(animationId)
+		animationId = null
+	}
+	if (stream) {
+		stream.getTracks().forEach(track => track.stop())
+		stream = null
+	}
+	showScanner.value = false
+}
+
 const formatVision = (field) => {
 	const value = parseFloat(formData.value[field])
 	if (!isNaN(value)) {
@@ -309,4 +400,36 @@ const handleSave = async () => {
 		flex: 1;
 	}
 }
+
+.scanner-popup {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+	background: #000;
+}
+
+.scanner-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 20rpx;
+	background: #fff;
+}
+
+.scanner-body {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	overflow: hidden;
+}
+
+.scanner-video {
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+}
+
+
 </style>

+ 3 - 0
vite.config.js

@@ -43,4 +43,7 @@ export default defineConfig({
 			},
 		},
 	},
+	optimizeDeps: {
+		exclude: ['vue-qrcode-reader']
+	}
 })