效果图:
子组件:
<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>