微信实名认证组件

发布于:2025-08-17 ⋅ 阅读:(17) ⋅ 点赞:(0)

效果图:

        

子组件:

<template>
	<view class="face-recognition-container" :style="{backgroundColor: bgColor}" v-if="showModal">
		<!-- 圆形采集框 -->
		<view class="circle-mask">
			<camera class="camera" device-position="front" flash="off" resolution="medium" v-if="cameraActive"
				@ready="initCamera" @error="cameraError" @initdone="initCamera"></camera>
		</view>

		<!-- 状态提示 -->
		<view class="status-panel">
			<view class="timer" v-if="isRecording">{{ countdown }}s</view>
			<view class="tips">{{ tipsText }}</view>
		</view>

		<!-- 操作按钮 -->
		<view class="control-panel">
			<button class="btn-record" :class="{recording: isRecording}" @click="handleRecord" :disabled="!isReady">
				{{ isRecording ? '采集中...' : '开始采集' }}
			</button>
			<button class="btn-cancel" @click="handleCancel" v-if="showCancel">
				取消
			</button>
		</view>
	</view>
</template>

<script>
	import ServerConfig from '@/common/utils/config.js';
	const RECORD_TIME = 15; // 录制时长(秒)
	const QUALITY_THRESHOLD = 0.8; // 质量合格阈值

	export default {
		data() {
			return {
				showModal: false,
				cameraActive: true,
				isReady: false,
				isRecording: false,
				tipsText: '请将人脸对准圆圈',
				bgColor: 'rgba(0,0,0,0.7)',
				countdown: RECORD_TIME,
				showCancel: true,
				vkSession: null,
				countdownTimer: null,
				bestFaceData: {
					face: null,
					quality: 0,
					timestamp: 0
				},
				bgColors: [
					'rgba(0, 255, 0, 0.7)',    // 绿色
					'rgba(255, 255, 0, 0.7)',   // 黄色
					'rgba(255, 255, 255, 0.7)',  // 紫色
					'rgba(0, 255, 255, 0.7)',    // 橙色
					'rgba(255, 0, 0, 0.7)'      // 红色
				],
				cameraContext: null,
				videoPath: '',
				faceDetectionCount: 0,
				listener: null
			};
		},
		methods: {
			show() {
				this.showModal = true;
				this.cameraActive = true;
				this.cameraContext = wx.createCameraContext();
			},

			hide() {
				this.showModal = false;
				this.stopDetection();
			},

			initCamera() {
				this.initFaceDetection();
			},

			initFaceDetection() {
				this.stopDetection();

				try {
					wx.initFaceDetect();
					
					this.listener = this.cameraContext.onCameraFrame((frame) => {
						if (this.isVerify || this.isRecording) return;
						
						wx.faceDetect({
							frameBuffer: frame.data,
							width: frame.width,
							height: frame.height,
							enablePoint: true,
							enableConf: true,
							enableAngle: true,
							enableMultiFace: true,
							success: (faceData) => {
								this.handleFaceDetection(faceData, frame);
							},
							fail: (err) => {
								this.handleFaceError(err);
							}
						});
					});
					
					this.listener.start();
					this.isReady = true;
					this.tipsText = '请将人脸对准圆圈';

				} catch (e) {
					console.error('人脸检测初始化异常:', e);
					this.tipsText = '人脸识别不可用';
					this.showCancel = true;
				}
			},

			handleFaceDetection(faceData, frame) {
				if (!faceData.faceInfo || faceData.faceInfo.length === 0) {
					this.tipsText = '请正对摄像头';
					return;
				}

				let face = faceData.faceInfo[0];
				if (face.x == -1 || face.y == -1) {
					this.tipsText = '检测不到人';
					return;
				}

				// 多人检测
				if (faceData.faceInfo.length > 1) {
					this.tipsText = '请保证只有一个人';
					return;
				}

				// 角度检测
				const { pitch, roll, yaw } = face.angleArray;
				const standard = 0.5;
				if (Math.abs(pitch) >= standard || Math.abs(roll) >= standard || Math.abs(yaw) >= standard) {
					this.tipsText = '请平视摄像头';
					return;
				}

				// 五官遮挡检测
				if (face.confArray.global <= QUALITY_THRESHOLD || 
					face.confArray.leftEye <= QUALITY_THRESHOLD ||
					face.confArray.mouth <= QUALITY_THRESHOLD || 
					face.confArray.nose <= QUALITY_THRESHOLD ||
					face.confArray.rightEye <= QUALITY_THRESHOLD) {
					this.tipsText = '请勿遮挡五官';
					return;
				}

				// 位置检测
				const centerWidth = 250;
				const centerHeight = 250;
				if (face.x < (frame.width - centerWidth)/2 || 
					face.x > (frame.width + centerWidth)/2 ||
					face.y < (frame.height - centerHeight)/2 || 
					face.y > (frame.height + centerHeight)/2) {
					this.tipsText = '请将人脸对准中心位置';
					return;
				}

				// 录制时记录最佳人脸
				if (this.isRecording) {
					const currentQuality = face.confArray.global;
					if (!this.bestFaceData.face || currentQuality > this.bestFaceData.quality) {
						this.bestFaceData = {
							face: face,
							quality: currentQuality,
							timestamp: Date.now()
						};
					}
				} else {
					this.tipsText = '位置良好,可以开始采集';
				}
			},

			handleFaceError(err) {
				if (this.isVerify || this.isRecording) return;
				this.tipsText = err.x == -1 ? '检测不到人' : (err.errMsg || '网络错误');
			},

			handleRecord() {
				if (this.isRecording) return;
				
				// 重置最佳人脸记录
				this.bestFaceData = {
					face: null,
					quality: 0,
					timestamp: 0
				};
				
				this.showCancel = false;
				this.isRecording = true;
				this.countdown = RECORD_TIME;
				this.bgColor = 'rgba(30,50,80,0.7)';

				this.cameraContext.startRecord({
					success: () => {
						this.countdownTimer = setInterval(() => {
							this.countdown--;
							this.bgColor = this.bgColors[Math.floor(Math.random() * this.bgColors.length)];

							if (this.countdown <= 0) {
								this.stopRecording();
							}
						}, 1000);
					},
					fail: (err) => {
						console.error('录制失败:', err);
						this.tipsText = '录制失败,请重试';
						this.isRecording = false;
						this.showCancel = true;
					}
				});
			},

			stopRecording() {
				clearInterval(this.countdownTimer);
				this.tipsText = '视频处理中...';
				
				this.cameraContext.stopRecord({
					success: (res) => {
						this.videoPath = res.tempVideoPath;
						
						// 检查人脸质量是否合格
						if (this.bestFaceData.quality >= QUALITY_THRESHOLD) {
							this.uploadVideo();
						} else {
							this.handleUploadError('人脸质量不合格,请重新采集');
							this.reset();
						}
					},
					fail: (err) => {
						console.error('停止录制失败:', err);
						this.handleUploadError('视频处理失败');
						this.isRecording = false;
						this.showCancel = true;
					}
				});
			},

			uploadVideo() {
				uni.showLoading({
					title: '上传中...',
					mask: true
				});

				uni.uploadFile({
					url: ServerConfig.SERVER_URL + '/common/uploadVideo',
					filePath: this.videoPath,
					name: 'video',
					header: {
						'Authorization': uni.getStorageSync('token')
					},
					success: (res) => {
						uni.hideLoading();
						try {
							const data = JSON.parse(res.data);
							if (data.code === 200) {
								// this.$emit('success', {
								// 	videoUrl: data.url,
								// 	bestFace: this.bestFaceData.face
								// });
								this.$emit('success', data.url);
								this.tipsText = '上传成功';
								setTimeout(() => {
									this.reset();
								}, 1500);
							} else {
								this.handleUploadError(data.msg || '上传失败');
							}
						} catch (e) {
							this.handleUploadError('解析响应失败');
						}
					},
					fail: (err) => {
						this.handleUploadError('上传失败: ' + err.errMsg);
					}
				});
			},

			handleUploadError(msg) {
				uni.hideLoading();
				this.tipsText = msg;
				this.bgColor = 'rgba(80,30,30,0.7)';
				this.showCancel = true;
				this.isRecording = false;
				console.error(msg);
			},

			reset() {
				this.stopDetection();
				this.isReady = false;
				this.isRecording = false;
				this.tipsText = '请将人脸对准圆圈';
				this.bgColor = 'rgba(0,0,0,0.7)';
				this.showCancel = true;
				this.bestFaceData = {
					face: null,
					quality: 0,
					timestamp: 0
				};

				setTimeout(() => {
					this.initFaceDetection();
				}, 500);
			},

			handleCancel() {
				this.stopDetection();
				this.hide();
				this.$emit('cancel');
			},

			stopDetection() {
				if (this.countdownTimer) clearInterval(this.countdownTimer);
				if (this.listener) {
					try {
						this.listener.stop();
					} catch (e) {
						console.warn('停止帧监听异常:', e);
					}
				}
				if (this.isRecording) {
					this.cameraContext.stopRecord();
				}
				this.countdownTimer = null;
				this.listener = null;
			},

			cameraError(e) {
				console.error('Camera error:', e.detail);
				this.tipsText = '请允许使用摄像头';
				this.showCancel = true;
			}
		},

		beforeDestroy() {
			this.stopDetection();
		}
	};
</script>

<style lang="scss">
	.face-recognition-container {
		position: fixed;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		transition: background-color 0.3s;
		z-index: 1000;

		.circle-mask {
			width: 500rpx;
			height: 500rpx;
			border-radius: 50%;
			overflow: hidden;
			border: 4rpx solid #07C160;
			box-shadow: 0 0 30rpx rgba(7, 193, 96, 0.5);
			margin-top: -200px;

			.camera {
				width: 100%;
				height: 100%;
			}
		}

		.status-panel {
			margin-top: 60rpx;
			width: 80%;
			text-align: center;

			.timer {
				font-size: 48rpx;
				color: #FFFFFF;
				margin-bottom: 20rpx;
				font-weight: bold;
			}

			.tips {
				font-size: 32rpx;
				color: #FFFFFF;
				margin-bottom: 30rpx;
			}

			.quality-indicator {
				height: 8rpx;
				background: linear-gradient(to right, #FF4D4F, #FAAD14, #52C41A);
				border-radius: 4rpx;
				transition: width 0.3s;
			}
		}

		.control-panel {
			position: absolute;
			bottom: 100rpx;
			width: 100%;
			display: flex;
			justify-content: center;
			gap: 40rpx;

			button {
				margin: 0;
				border: none;
				color: #FFFFFF;
				font-size: 32rpx;
				border-radius: 50rpx;
				padding: 0 60rpx;
				height: 80rpx;
				line-height: 80rpx;

				&.btn-record {
					background: #07C160;

					&.recording {
						background: #1890FF;
					}
				}

				&.btn-cancel {
					background: rgba(255, 77, 79, 0.8);
				}

				&[disabled] {
					background: #CCCCCC !important;
				}
			}
		}
	}
</style>

父组件:

<!-- pages/mine/realnameAuth/realnameAuth.vue -->
<template>
	<view class="container">
		<view class="auth-card" v-show="isShow">
			<view class="card-header">
				<text class="card-title">{{ isVerified ? '已完成认证' : '实名认证' }}</text>
				<text class="card-subtitle">{{ isVerified ? '您已完成实名认证,无需重复认证' : '请填写真实姓名和身份证号码' }}</text>
			</view>

			<view v-if="!isVerified">
				<view class="form-group">
					<text class="form-label">真实姓名</text>
					<input class="form-input" v-model="formData.name" placeholder="请输入真实姓名"
						placeholder-class="placeholder-style" @blur="validateField('name')"/>
					<view class="error-message" v-if="errors.name">{{ errors.name }}</view>
				</view>

				<view class="form-group">
					<text class="form-label">身份证号</text>
					<input class="form-input" v-model="formData.idCard" placeholder="请输入身份证号码"
						placeholder-class="placeholder-style" maxlength="18"  @blur="validateField('idCard')"/>
					<view class="error-message" v-if="errors.idCard">{{ errors.idCard }}</view>	
				</view>

				<view class="form-group">
					<text class="form-label">人脸识别</text>
					<button class="submit-btn" 
						:class="{ 'disabled': !isStartRecognition }"
						:disabled="!isStartRecognition"
						 @click="startRecognition">
						开始认证
					</button>
				</view>

				
			</view>

			<view v-else class="verified-info">
				<view class="success-icon">✓</view>
				<text class="success-text">您已完成实名认证</text>
				<button class="back-btn" @click="goBack">返回</button>
			</view>
		</view>

		<!-- 提示信息 -->
		<view class="tips" v-show="isShow">
			<text class="tip-text" v-if="!isVerified">· 实名认证信息一经提交不可修改</text>
			<text class="tip-text" v-if="!isVerified">· 请确保信息真实有效</text>
		</view>

		

		<face-recognition ref="faceRecognition" @success="handleSuccess" @cancel="handleCancel" />
	</view>
</template>

<script>
	import {
		Id2MetaVerify
	} from '@/common/api/user.js'
	import {
		getInfo,
		checkLogin
	} from '@/common/api/login.js';
	import FaceRecognition from '@/component/face.vue'
	export default {
		components:{
			FaceRecognition
		},
		data() {
			return {
				// 表单数据
				formData: {
					name: '',
					idCard: '',
					facePath: '' // 存储选择的图片路径
				},
				errors: {
					name: '',
					idCard: ''
				},
				isVerified: false,//是否已实名
				isStartRecognition : false , //是否可以人脸识别
				isShow: true
			}
		},
		computed: {
			// 表单验证
			isFormValid() {
				return this.validateField('name') != '' ||
					this.validateField('idCard') !== '' 
			}
		},
		watch: {
			formData: {
				handler(newVal) {
					if (newVal) {
						this.isStartRecognition = this.validateField('name') && this.validateField('idCard')
					}
				},
				deep: true,
				immediate: true,
			},
		},
		onShow() {
			// 检查用户是否已登录
			checkLogin().then(res => {
				if (res.code != 200) {
					uni.clearStorageSync();
					this.$refs.loginModalRef.onShow()
					return
				}
			})
			this.isVerified = getApp().globalData.userInfo.isVerified == 1;
		},
		
		methods: {
			startRecognition() {
				this.isShow = false
				this.$refs.faceRecognition.show();
			},
			handleSuccess(imageUrl) {
				this.isShow = true
				console.log('人脸图片URL:', imageUrl);
				// 更新用户头像等操作
				this.facePath = imageUrl
				
				this.submitAuth()
			},
			handleCancel() {
				this.isShow = true
				console.log('用户取消了采集');
			},
			validateField(field) {
				switch (field) {
					case 'name':
						if (!this.formData.name) {
							this.errors.name = '请输入姓名'
							return false;
						} else {
							this.errors.name = ''
							return true;
						}
			
					case 'idCard':
						if (!this.formData.idCard) {
						    this.errors.idCard = '请输入身份证号码'
							return false;
						} else if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(this.formData.idCard)) {
						    this.errors.idCard = '请输入有效的身份证号码'
							return false;
						} else {
							this.errors.idCard = ''
							return true;
						}
					case 'facePath':
						if (!this.formData.facePath) {
						    this.errors.facePath = '人脸识别未通过,请重新识别'
							return false;
						} else {
							this.errors.idCard = ''
							return true;
						}
				}
			},

			// 提交认证
			async submitAuth() {
				let isSubmit = this.validateField('name') && this.validateField('idCard') && this.validateField("facePath")
				if(!isSubmit){
					return
				}
				
				uni.showLoading({
					title:"认证中..."
				})

				try {
					let data = {
						idNumber:this.formData.idCard,
						idName:this.formData.name,
						url:this.formData.facePath
					}
					let response = await Id2MetaVerify(data);

					if (response.code === 200) {
						uni.showToast({
							title: '认证成功',
							icon: 'success'
						})
						
						uni.reLaunch({
							url:'/pages/mine/mine'
						})
					} else {
						uni.showToast({
							title: response.msg || '认证失败,认证信息有误',
							icon: 'none'
						})
					}
				} catch (error) {
					uni.showToast({
						title: '认证请求失败',
						icon: 'none'
					})
					console.error('认证请求失败:', error)
				} finally {
					uni.hideLoading();
				}
			},

			
			// 返回上一页
			goBack() {
				uni.navigateBack()
			}
		}
	}
</script>


<style lang="scss">
	.container {
		background-color: #121326;
		min-height: 100vh;
		padding: 20rpx 30rpx;
	}

	.auth-card {
		background-color: #1e1f38;
		border-radius: 20rpx;
		padding: 40rpx 30rpx;
		margin-top: 30rpx;
		box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.2);
	}

	.card-header {
		text-align: center;
		margin-bottom: 50rpx;
	}

	.card-title {
		font-size: 36rpx;
		font-weight: 600;
		color: #C0C2CF;
		display: block;
		margin-bottom: 16rpx;
	}

	.card-subtitle {
		font-size: 26rpx;
		color: #888999;
	}

	.form-group {
		margin-bottom: 40rpx;
	}

	.form-label {
		display: block;
		font-size: 28rpx;
		color: #C0C2CF;
		margin-bottom: 16rpx;
	}

	.form-input {
		width: 100%;
		height: 80rpx;
		background-color: #2a2c40;
		border-radius: 12rpx;
		padding: 0 24rpx;
		font-size: 28rpx;
		color: #C0C2CF;
		border: 1rpx solid #3a3c58;
		box-sizing: border-box;
	}

	.placeholder-style {
		color: #888999;
	}

	.upload-btn {
		width: 100%;
		height: 80rpx;
		background-color: #2a2c40;
		border-radius: 12rpx;
		color: #C0C2CF;
		font-size: 28rpx;
		border: 1rpx solid #3a3c58;
		margin-bottom: 20rpx;
	}

	.preview-image {
		width: 200rpx;
		height: 200rpx;
		border-radius: 12rpx;
		margin-top: 20rpx;
	}

	.submit-btn {
		width: 100%;
		height: 80rpx;
		background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
		border-radius: 12rpx;
		color: #fff;
		font-size: 32rpx;
		font-weight: 500;
		margin-top: 20rpx;
		border: none;

		&.disabled {
			background: #3a3c58;
			color: #888999;
		}
	}

	.tips {
		margin-top: 40rpx;
		padding: 0 20rpx;
	}

	.tip-text {
		display: block;
		font-size: 24rpx;
		color: #888999;
		margin-bottom: 10rpx;
	}

	.verified-info {
		text-align: center;
		padding: 40rpx 0;
	}

	.success-icon {
		width: 100rpx;
		height: 100rpx;
		border-radius: 50%;
		background-color: #4caf50;
		color: white;
		font-size: 60rpx;
		line-height: 100rpx;
		margin: 0 auto 30rpx;
	}

	.success-text {
		font-size: 32rpx;
		color: #C0C2CF;
		display: block;
		margin-bottom: 40rpx;
	}

	.back-btn {
		width: 100%;
		height: 80rpx;
		background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
		border-radius: 12rpx;
		color: #fff;
		font-size: 32rpx;
		font-weight: 500;
		border: none;
	}
	.error-message {
		color: #ff6b6b;
		font-size: 24rpx;
		margin-top: 8rpx;
		display: flex;
		align-items: center;
	}

	.error-message::before {
		content: "!";
		display: inline-block;
		width: 20rpx;
		height: 20rpx;
		border-radius: 50%;
		background-color: #ff6b6b;
		color: white;
		font-size: 16rpx;
		text-align: center;
		line-height: 20rpx;
		margin-right: 6rpx;
	}

</style>


网站公告

今日签到

点亮在社区的每一天
去签到