地址加人脸验证页面
This commit is contained in:
parent
233a10399d
commit
96fe22b0f1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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"]}]
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue