地址加人脸验证页面

This commit is contained in:
Songsl 2026-05-14 10:36:23 +08:00
parent 233a10399d
commit 96fe22b0f1
13 changed files with 1499 additions and 173 deletions

View File

@ -1,8 +1,8 @@
# 开发环境标识Vite 内置变量,无需修改)
NODE_ENV = development
# 开发环境接口基础地址(对应你配置的 http://18.167.120.9:8085/api
VITE_API_BASE_URL = http://18.167.120.9:8085/api
# 开发环境接口基础地址(对应你配置的 https://adminapi.nplus.top/api
VITE_API_BASE_URL = https://adminapi.nplus.top/api
# 请求超时时间(毫秒)
VITE_REQUEST_TIMEOUT = 10000

View File

@ -2,7 +2,7 @@
NODE_ENV = production
# 生产环境接口基础地址
VITE_API_BASE_URL = http://18.167.120.9:8085/api
VITE_API_BASE_URL = https://adminapi.nplus.top/api
# 请求超时时间(毫秒)
VITE_REQUEST_TIMEOUT = 10000

View File

@ -27,6 +27,7 @@
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"element-plus": "^2.13.2",
"face-api.js": "^0.22.2",
"file-saver": "^2.0.5",
"mqtt": "^5.15.1",
"pinia": "^3.0.4",
@ -42,6 +43,7 @@
"@types/file-saver": "^2.0.7",
"@types/node": "^24.10.13",
"@types/xlsx": "^0.0.36",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -0,0 +1 @@
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]

View File

@ -0,0 +1,40 @@
import { request } from '@/api'
/**
*
*/
export async function uploadCard(params: any): Promise<any> {
return request.post<any>('/aws/uploadCard', params, {
headers: {
'Content-Type': 'multipart/form-data', // 表单上传必须的头
},
})
}
/**
*
*/
export async function uploadLivePhoto(params: any): Promise<any> {
return request.post<any>('/aws/uploadLivePhoto', params, {
headers: {
'Content-Type': 'multipart/form-data', // 表单上传必须的头
}
})
}
/**
*
*/
export async function checkLiveness(): Promise<any> {
return request.post<any>('/aws/checkLiveness')
}
/**
*
*/
export async function verifyCard(): Promise<any> {
return request.post<any>('/aws/verifyCard')
}

View File

@ -70,6 +70,18 @@ const routes = [
},
],
},
{
path: '/realName',
name: '实名认证',
component: () => import('@/views/realName/index.vue'),
meta: { requiresAuth: true, title: '实名认证', keepAlive: false },
},
{
path: '/livenessVerification',
name: '活体认证',
component: () => import('@/views/livenessVerification/index.vue'),
meta: { requiresAuth: true, title: '活体认证', keepAlive: false },
},
{
path: '/:pathMatch(.*)*',
redirect: () => {

View File

@ -1,201 +1,218 @@
import { getStorage, setStorage, removeStorage } from '@/utils/storage';
import { loginApi, registerApi, logoutApi, forgotPasswordApi, changePasswordApi, sendCodeApi } from '@/api/modules/user';
import type { LoginParams, RegisterParams, ForgotPasswordParams, ChangePasswordParams, SendCodeParams } from '@/api/types/user';
import type { UserInfo, MenuItem, PermissionItem } from '@/stores/types/user';
import { formatMenuToElMenu, type ElMenuItem } from '@/utils/menuFormat';
import { getStorage, setStorage, removeStorage } from '@/utils/storage'
import {
loginApi,
registerApi,
logoutApi,
forgotPasswordApi,
changePasswordApi,
sendCodeApi,
} from '@/api/modules/user'
import type {
LoginParams,
RegisterParams,
ForgotPasswordParams,
ChangePasswordParams,
SendCodeParams,
} from '@/api/types/user'
import type { UserInfo, MenuItem, PermissionItem } from '@/stores/types/user'
import { formatMenuToElMenu, type ElMenuItem } from '@/utils/menuFormat'
import { handleRouter, setRouter } from '@/router/handleRouter.ts'
// 定义用户 Store
export const useUserStore = defineStore('user', () => {
const router = useRouter();
const router = useRouter()
// ========== 状态定义(与后端返回数据对应) ==========
// 用户/菜单和权限状态
const userInfo = ref<UserInfo | null>(getStorage('userInfo') || null);
const token = ref<string>(getStorage('token') || '');
const menusInfo = ref<MenuItem[]>(getStorage('menusInfo') || []);
const permissionsInfo = ref<PermissionItem[]>(getStorage('permissionsInfo') || []);
const userInfo = ref<UserInfo | null>(getStorage('userInfo') || null)
const token = ref<string>(getStorage('token') || '')
const menusInfo = ref<MenuItem[]>(getStorage('menusInfo') || [])
const permissionsInfo = ref<PermissionItem[]>(getStorage('permissionsInfo') || [])
// ========== 计算属性 ==========
const isLoggedIn = computed(() => !!token.value);
const isLoggedIn = computed(() => !!token.value)
// const permissionKeys = computed(() => permissions.value.map(item => item.name));
// 格式化后的 el-menu 数据(自动更新)
const sideMenu = computed<ElMenuItem[]>(() => formatMenuToElMenu(menusInfo.value));
/**
*
* +
*/
const setUserInfo = (set: any) => {
token.value = set.user.token;
userInfo.value = set.user;
menusInfo.value = set.menus_info;
permissionsInfo.value = set.permissions_info;
const sideMenu = computed<ElMenuItem[]>(() => formatMenuToElMenu(menusInfo.value))
/**
*
* +
*/
const setUserInfo = (set: any) => {
token.value = set.user.token
userInfo.value = set.user
menusInfo.value = set.menus_info
permissionsInfo.value = set.permissions_info
const btnAuth = (set.permissions_info || []).map((item: any) => item.code).filter((item: any) => item)
const btnAuth = (set.permissions_info || [])
.map((item: any) => item.code)
.filter((item: any) => item)
// 保存到本地存储
setStorage('token', set.user.token);
setStorage('userInfo', set.user);
setStorage('menusInfo', set.menus_info);
setStorage('permissionsInfo', set.permissions_info);
setStorage('btnAuth', btnAuth)
setRouter()
};
/**
*
* +
*/
const clearUserInfo = () => {
// 重置 Pinia 中的状态
token.value = '';
userInfo.value = null;
menusInfo.value = [];
permissionsInfo.value = [];
// 清除本地存储的用户数据
removeStorage('token');
removeStorage('userInfo');
removeStorage('menusInfo');
removeStorage('permissionsInfo');
removeStorage('device_no');
console.log('用户信息已清空');
};
/**
*
* @param params
*/
const login = async (params: LoginParams) => {
try {
const res = await loginApi(params);
if (res.isUserNotExist) {
// 用户不存在,跳转到注册页面
router.push('/user/register');
return;
}
setUserInfo(res);
// 跳转到首页
router.push('/');
} catch (error) {
console.error('登录失败', error);
throw error;
// 保存到本地存储
setStorage('token', set.user.token)
setStorage('userInfo', set.user)
setStorage('menusInfo', set.menus_info)
setStorage('permissionsInfo', set.permissions_info)
setStorage('btnAuth', btnAuth)
setRouter()
}
};
/**
/**
*
* +
*/
const clearUserInfo = () => {
// 重置 Pinia 中的状态
token.value = ''
userInfo.value = null
menusInfo.value = []
permissionsInfo.value = []
// 清除本地存储的用户数据
removeStorage('token')
removeStorage('userInfo')
removeStorage('menusInfo')
removeStorage('permissionsInfo')
removeStorage('device_no')
console.log('用户信息已清空')
}
/**
*
* @param params
*/
const login = async (params: LoginParams) => {
try {
const res = await loginApi(params)
if (res.isUserNotExist) {
// 用户不存在,跳转到注册页面
router.push('/user/register')
return
}
setUserInfo(res)
// 跳转到首页
if (res.user.real_check !== 1) {
router.push('/')
} else {
router.push('/realName')
}
} catch (error) {
console.error('登录失败', error)
throw error
}
}
/**
*
* @param params
*/
const register = async (params: RegisterParams) => {
try {
const res = await registerApi(params);
//跳转登录页面
router.push('/user/login');
} catch (error) {
console.error('注册失败', error);
throw error;
const register = async (params: RegisterParams) => {
try {
const res = await registerApi(params)
//跳转登录页面
router.push('/user/login')
} catch (error) {
console.error('注册失败', error)
throw error
}
}
};
/**
* 退
*/
const logout = async (params: { device_no: string }) => {
try {
await logoutApi(params);
} catch (error) {
console.error('退出登录接口调用失败', error);
} finally {
clearUserInfo();
/**
* 退
*/
const logout = async (params: { device_no: string }) => {
try {
await logoutApi(params)
} catch (error) {
console.error('退出登录接口调用失败', error)
} finally {
clearUserInfo()
// 跳转到登录页面
router.push('/user/login');
// 跳转到登录页面
router.push('/user/login')
}
}
};
/**
*
*/
const forgotPassword = async (params: ForgotPasswordParams) => {
try {
await forgotPasswordApi(params);
// 跳转登录页面
router.push('/user/login');
} catch (error) {
console.error('重置密码失败', error);
throw error;
/**
*
*/
const forgotPassword = async (params: ForgotPasswordParams) => {
try {
await forgotPasswordApi(params)
// 跳转登录页面
router.push('/user/login')
} catch (error) {
console.error('重置密码失败', error)
throw error
}
}
};
/**
*
*/
const changePassword = async (params: ChangePasswordParams) => {
try {
await changePasswordApi(params);
// 跳转登录页面
clearUserInfo();
router.push('/user/login');
} catch (error) {
console.error('修改密码失败', error);
throw error;
/**
*
*/
const changePassword = async (params: ChangePasswordParams) => {
try {
await changePasswordApi(params)
// 跳转登录页面
clearUserInfo()
router.push('/user/login')
} catch (error) {
console.error('修改密码失败', error)
throw error
}
}
};
/**
*
*/
const sendCode = async (params: SendCodeParams) => {
try {
const res = await sendCodeApi(params);
} catch (error) {
console.error('发送验证码失败', error);
throw error;
/**
*
*/
const sendCode = async (params: SendCodeParams) => {
try {
const res = await sendCodeApi(params)
} catch (error) {
console.error('发送验证码失败', error)
throw error
}
}
/**
*
*/
const switchAccount = async () => {
clearUserInfo()
router.push('/user/login')
}
};
/**
*
*/
const switchAccount = async () => {
clearUserInfo();
router.push('/user/login');
};
/**
*
* @param data
*/
const updateUserInfo = (data: Partial<UserInfo>) => {
if (userInfo.value) {
// 合并更新用户信息
userInfo.value = { ...userInfo.value, ...data };
// 更新本地存储
setStorage('userInfo', userInfo.value);
/**
*
* @param data
*/
const updateUserInfo = (data: Partial<UserInfo>) => {
if (userInfo.value) {
// 合并更新用户信息
userInfo.value = { ...userInfo.value, ...data }
// 更新本地存储
setStorage('userInfo', userInfo.value)
}
}
};
return {
// 状态
token,
userInfo,
menusInfo,
permissionsInfo,
// 计算属性
isLoggedIn,
sideMenu,
// 方法(箭头函数)
login,
setUserInfo,
register,
logout,
forgotPassword,
changePassword,
sendCode,
switchAccount,
updateUserInfo
};
});
return {
// 状态
token,
userInfo,
menusInfo,
permissionsInfo,
// 计算属性
isLoggedIn,
sideMenu,
// 方法(箭头函数)
login,
setUserInfo,
register,
logout,
forgotPassword,
changePassword,
sendCode,
switchAccount,
updateUserInfo,
}
})

View File

@ -0,0 +1,235 @@
<template>
<div class="container">
<h2>人脸认证</h2>
<div class="video-box">
<video ref="video" autoplay muted playsinline webkit-playsinline x5-playsinline></video>
</div>
<canvas ref="overlay" class="overlay"></canvas>
<!-- <div class="message">{{ message }}</div>-->
<div v-if="currentAction" class="action-tip">
{{ actionTips[currentAction] }}
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as faceapi from 'face-api.js'
import { checkLiveness, uploadLivePhoto, verifyCard } from '@/api/modules/livenessVerification.ts'
import { useRouter } from 'vue-router'
import { clearStorage } from '@/utils/storage/index.ts'
const router = useRouter()
// DOM refs
const video = ref(null)
const overlay = ref(null)
//
const message = ref('初始化中...')
const capturedFiles = reactive({ front: null, mouth: null, yaw: null })
//
const currentAction = ref(null)
const actionTips = {
front: '请保持正面朝向摄像头',
mouth: '请张开嘴',
yaw: '请左右摇头',
}
//
const MOUTH_OPEN_THRESHOLD = 15
const YAW_THRESHOLD = 20
// --------- ---------
async function init() {
try {
message.value = '加载模型中...'
await faceapi.nets.tinyFaceDetector.load('/models/face')
await faceapi.nets.faceLandmark68Net.load('/models/face')
message.value = '模型加载完成,打开摄像头...'
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
video.value.srcObject = stream
await video.value.play()
message.value = '准备就绪'
startDetection()
} catch (err) {
console.error(err)
message.value = '摄像头或模型加载失败,请检查'
}
}
function startDetection() {
const actions = ['front', 'mouth', 'yaw']
let index = 0
const nextAction = async () => {
if (index >= actions.length) {
message.value = '认证完成!'
console.log('抓拍文件结果:', capturedFiles)
let formData = new FormData()
formData.append('live_photo1', capturedFiles.front)
formData.append('live_photo2', capturedFiles.mouth)
formData.append('live_photo3', capturedFiles.yaw)
try {
await uploadLivePhoto(formData)
const data = await checkLiveness()
if (!data.flag) {
ElMessage.error('检测失败,请重新检测')
await init()
return
}
const verifyCardData = await verifyCard()
if (!verifyCardData.flag) {
router.back()
return ElMessage.error('检测失败')
}
clearStorage()
router.push('/user/login')
ElMessage.warning('请重新登录')
} catch (e) {
ElMessage.error(e.message)
}
return
}
currentAction.value = actions[index]
await detectAction(actions[index])
index++
nextAction()
}
nextAction()
}
async function detectAction(action) {
return new Promise((resolve) => {
const interval = setInterval(async () => {
const detection = await faceapi
.detectSingleFace(video.value, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
if (!detection) return
const landmarks = detection.landmarks
if (action === 'front') {
const leftEye = landmarks.getLeftEye()
const rightEye = landmarks.getRightEye()
const eyeDiff = Math.abs(leftEye[0].y - rightEye[3].y)
if (eyeDiff < 10) {
capturedFiles.front = await captureImage(video.value, 'front.png')
clearInterval(interval)
resolve()
}
}
if (action === 'mouth') {
if (isMouthOpen(landmarks)) {
capturedFiles.mouth = await captureImage(video.value, 'mouth.png')
clearInterval(interval)
resolve()
}
}
if (action === 'yaw') {
const yaw = estimateYaw(landmarks)
if (Math.abs(yaw) > YAW_THRESHOLD) {
capturedFiles.yaw = await captureImage(video.value, 'yaw.png')
clearInterval(interval)
resolve()
}
}
}, 200)
})
}
// ----------------- -----------------
async function captureImage(videoEl, fileName) {
const canvas = document.createElement('canvas')
canvas.width = videoEl.videoWidth
canvas.height = videoEl.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(videoEl, 0, 0)
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
if (blob) {
const file = new File([blob], fileName, { type: 'image/jpeg' })
resolve(file)
}
},
'image/jpeg',
0.95,
)
})
}
function isMouthOpen(landmarks) {
const mouth = landmarks.getMouth()
const top = mouth[13]
const bottom = mouth[19]
const distance = Math.hypot(top.x - bottom.x, top.y - bottom.y)
return distance > MOUTH_OPEN_THRESHOLD
}
function estimateYaw(landmarks) {
const nose = landmarks.getNose()
const noseTip = nose[3]
const noseBase = nose[0]
return noseTip.x - noseBase.x
}
onMounted(() => {
init()
})
</script>
<style scoped>
.container {
position: relative;
width: 100%;
max-width: 480px;
margin: 20px auto;
}
h2 {
text-align: center;
line-height: 40px;
}
.video-box {
width: 80vw;
height: 80vw;
border-radius: 50%;
overflow: hidden;
margin: 20px auto;
padding: 0;
}
video {
width: 100%;
height: 100%;
transform: scaleX(-1);
object-fit: cover;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.message {
text-align: center;
margin-top: 10px;
font-weight: bold;
}
.action-tip {
text-align: center;
margin-top: 80px;
font-size: 18px;
color: #333;
}
</style>

1015
src/views/realName/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import { loadEnv, defineConfig, ConfigEnv } from 'vite'
import type { UserConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import basicSsl from '@vitejs/plugin-basic-ssl'
// 自动导入插件
import AutoImport from 'unplugin-auto-import/vite'
@ -37,6 +38,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
dts: 'src/components.d.ts',
dirs: ['src/components'],
}),
basicSsl(),
],
resolve: {
alias: {
@ -84,6 +86,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
host: true,
port: 9999,
open: true,
https: true,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL,