feat(auth): 添加滑动验证码功能
- 在 auth API 中新增 userCaptcha 和 userCaptchaValidate 方法 - 在登录页面集成滑动验证码组件 - 实现验证码获取
This commit is contained in:
parent
4041b45cca
commit
85523e8321
@ -22,4 +22,18 @@ export async function userUpdate(data) {
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
export async function userCaptcha(data) {
|
||||
return await request( {
|
||||
url:'/api/v1/m/user/captcha',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
export async function userCaptchaValidate(data) {
|
||||
return await request( {
|
||||
url:'/mall/user/validate/captcha',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
307
app/components/YourPuzzleComponent.vue
Normal file
307
app/components/YourPuzzleComponent.vue
Normal file
@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="puzzle-container">
|
||||
<div class="puzzle-box" :style="{ height: boxHeight + 'px' }">
|
||||
<!-- 背景图 -->
|
||||
<img :src="bgImageUrl" class="bg-image" ref="bgImage" @load="onImageLoad">
|
||||
<!-- 滑块 -->
|
||||
<div
|
||||
class="slider-block"
|
||||
:style="{
|
||||
backgroundImage: `url(${sliderImageUrl})`,
|
||||
top: `${blockY}px`,
|
||||
left: `${moveX}px`,
|
||||
visibility: loaded ? 'visible' : 'hidden'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 滑动条 -->
|
||||
<div class="slider-container">
|
||||
<div class="slider-track">
|
||||
<div
|
||||
class="slider-bar"
|
||||
:style="{ width: `${moveX}px` }"
|
||||
></div>
|
||||
<div
|
||||
class="slider-button"
|
||||
:style="{ left: `${moveX}px` }"
|
||||
@mousedown="handleMouseDown"
|
||||
@touchstart="handleTouchStart"
|
||||
>
|
||||
<div class="slider-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证结果提示 -->
|
||||
<div v-if="verifySuccess || verifyError" class="verify-result-bar" :class="{ 'success': verifySuccess, 'error': verifyError }">
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
blockY: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
bgImageUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
sliderImageUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['leave'])
|
||||
|
||||
// 响应式状态
|
||||
const moveX = ref(0)
|
||||
const startX = ref(0)
|
||||
const oldMoveX = ref(0)
|
||||
const maxMoveX = ref(0)
|
||||
const boxHeight = ref(0)
|
||||
const loaded = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const verifySuccess = ref(false)
|
||||
const verifyError = ref(false)
|
||||
const verifyTip = ref('')
|
||||
|
||||
// DOM引用
|
||||
const bgImage = ref(null)
|
||||
|
||||
// 方法
|
||||
const onImageLoad = () => {
|
||||
if (bgImage.value) {
|
||||
const img = bgImage.value
|
||||
const scale = img.width / img.naturalWidth // 计算图片缩放比例
|
||||
boxHeight.value = img.height
|
||||
|
||||
// 根据图片实际显示大小调整滑块大小
|
||||
const blockSize = Math.round(50 * scale)
|
||||
document.documentElement.style.setProperty('--block-size', blockSize + 'px')
|
||||
|
||||
maxMoveX.value = img.width - blockSize // 使用实际显示的滑块大小计算最大移动距离
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (event) => {
|
||||
event.preventDefault()
|
||||
startX.value = event.clientX
|
||||
oldMoveX.value = moveX.value
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const handleTouchStart = (event) => {
|
||||
event.preventDefault()
|
||||
const touch = event.touches[0]
|
||||
startX.value = touch.clientX
|
||||
oldMoveX.value = moveX.value
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const move = (clientX) => {
|
||||
let diff = clientX - startX.value
|
||||
let newMoveX = oldMoveX.value + diff
|
||||
|
||||
// 限制移动范围
|
||||
if (newMoveX < 0) newMoveX = 0
|
||||
if (newMoveX > maxMoveX.value) newMoveX = maxMoveX.value
|
||||
|
||||
moveX.value = Math.round(newMoveX) // 取整数避免小数点导致的模糊
|
||||
}
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
if (!isDragging.value) return
|
||||
move(event.clientX)
|
||||
}
|
||||
|
||||
const handleTouchMove = (event) => {
|
||||
if (!isDragging.value) return
|
||||
event.preventDefault() // 防止页面滚动
|
||||
move(event.touches[0].clientX)
|
||||
}
|
||||
|
||||
const handleMouseUp = async () => {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
|
||||
try {
|
||||
emit('leave', moveX.value, (success) => {
|
||||
if (success) {
|
||||
verifySuccess.value = true
|
||||
verifyError.value = false
|
||||
verifyTip.value = '验证成功'
|
||||
} else {
|
||||
verifySuccess.value = false
|
||||
verifyError.value = true
|
||||
verifyTip.value = '验证失败'
|
||||
moveX.value = 0 // 验证失败,滑块返回原位
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
verifySuccess.value = false
|
||||
verifyError.value = true
|
||||
verifyTip.value = '验证失败'
|
||||
moveX.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
handleMouseUp()
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
window.addEventListener('touchmove', handleTouchMove)
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--block-size: 50px;
|
||||
}
|
||||
|
||||
.puzzle-container {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
touch-action: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.puzzle-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #f8f8f8;
|
||||
border-radius: 8px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.bg-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-block {
|
||||
position: absolute;
|
||||
width: var(--block-size);
|
||||
height: var(--block-size);
|
||||
background-size: 100% 100%;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-top: 15px;
|
||||
height: 40px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 20px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-bar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #91d5ff;
|
||||
border-radius: 20px;
|
||||
will-change: transform;
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.slider-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slider-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.verify-result-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.verify-result-bar.success {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.verify-result-bar.error {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 移除旧的提示样式 */
|
||||
.verify-tip {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
@ -1,15 +1,21 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import Vcode from "vue3-puzzle-vcode";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import countryCode from '../countryRegion/data/index.js'
|
||||
import {senCode, userLogin} from "@/api/auth/index.js";
|
||||
import {senCode, userLogin,userCaptcha,userCaptchaValidate} from "@/api/auth/index.js";
|
||||
import {authStore} from "@/stores/auth/index.js";
|
||||
import {message} from '@/components/x-message/useMessage.js'
|
||||
import {fddCheck} from "~/api/goods/index.js";
|
||||
import zu6020 from '@/static/images/zu6020@2x.png'
|
||||
import YourPuzzleComponent from '@/components/YourPuzzleComponent.vue'
|
||||
const {userInfo,token,selectedZone}= authStore()
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { locale } = useI18n()
|
||||
const imgs=ref([zu6020])
|
||||
console.log('zu6020');
|
||||
|
||||
definePageMeta({
|
||||
name: 'login',
|
||||
i18n: 'login.title'
|
||||
@ -79,21 +85,44 @@ onMounted(()=>{
|
||||
selectedCountry.value=route.query.countryName || defaultCountry.name
|
||||
})
|
||||
const vanSwipeRef=ref(null)
|
||||
const captcha=ref({
|
||||
"nonceStr": "397b5e08168c31c4",
|
||||
"blockX": 256
|
||||
})
|
||||
const captchaUrl=ref('')
|
||||
const captchaVerifyUrl=ref('')
|
||||
const blockY=ref(0)
|
||||
const getCode =async () => {
|
||||
loadingRef.value.loading1=true
|
||||
const res=await senCode({
|
||||
telNum:phoneNum.value,
|
||||
zone:selectedZone.value
|
||||
})
|
||||
loadingRef.value.loading1=false
|
||||
|
||||
const res=await userCaptcha({
|
||||
"canvasWidth": 320, //画布的宽度(canvasWidth大于41并且(canvasWidth-10)/2 - 1> blockWidth)
|
||||
"canvasHeight": 191, //画布的高度(canvasHeight大于26并且 canvasHeight - blockHeight > 11
|
||||
"blockWidth": 50, //块图像的宽度(blockWidth大于14)
|
||||
"blockHeight": 50, //块图像的高度(blockHeight大于14)
|
||||
// "blockRadius": 25, //块图像的圆角半径
|
||||
"place": 0 //图像来源标识,0表示URL下载,1表示本地文件 一般用0
|
||||
})
|
||||
if (res.status===0){
|
||||
|
||||
|
||||
captchaUrl.value=`data:image/png;base64,${res.data.canvasSrc}`
|
||||
captchaVerifyUrl.value=`data:image/png;base64,${res.data.blockSrc}`
|
||||
blockY.value=res.data.blockY
|
||||
captcha.value=res.data.nonceStr
|
||||
isShow.value=true
|
||||
}
|
||||
pane.value = 1
|
||||
vanSwipeRef.value?.swipeTo(pane.value)
|
||||
// loadingRef.value.loading1=true
|
||||
// const res=await senCode({
|
||||
// telNum:phoneNum.value,
|
||||
// zone:selectedZone.value
|
||||
// })
|
||||
// loadingRef.value.loading1=false
|
||||
// if (res.status===0){
|
||||
|
||||
|
||||
// }
|
||||
// pane.value = 1
|
||||
// vanSwipeRef.value?.swipeTo(pane.value)
|
||||
|
||||
startCountdown();
|
||||
// startCountdown();
|
||||
}
|
||||
const goBack = () => {
|
||||
code.value = ''
|
||||
@ -146,6 +175,27 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', () => {})
|
||||
})
|
||||
const isShow=ref(false)
|
||||
const onSuccess=()=>{
|
||||
// userCaptchaValidate()
|
||||
isShow.value=false
|
||||
}
|
||||
const onClose=()=>{
|
||||
isShow.value=false
|
||||
}
|
||||
const onLeave =async (moveX, callback) => {
|
||||
console.log('moveX',moveX);
|
||||
const res=await userCaptchaValidate({
|
||||
blockX:moveX,
|
||||
nonceStr:captcha.value.nonceStr
|
||||
})
|
||||
if (res.status===0){
|
||||
callback(true)
|
||||
}else {
|
||||
callback(false)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -221,6 +271,16 @@ onUnmounted(() => {
|
||||
<div v-if="!isKeyboardVisible" class="text-center text-14px absolute left-1/2 transform translate-x--1/2 bottom-20px">
|
||||
{{ $t('login.agreement') }}<span class="text-#3454AF " @click="$router.push('/privacyPolicy')">{{ $t('login.privacyPolicy') }}</span>
|
||||
</div>
|
||||
<van-popup v-model:show="isShow" round>
|
||||
<YourPuzzleComponent
|
||||
:show="true"
|
||||
:blockY="blockY"
|
||||
:bgImageUrl="captchaUrl"
|
||||
:sliderImageUrl="captchaVerifyUrl"
|
||||
@leave="onLeave"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -238,4 +298,15 @@ onUnmounted(() => {
|
||||
width: 41px;
|
||||
height: 41px;
|
||||
}
|
||||
|
||||
.verify-popup-content {
|
||||
width: 90vw;
|
||||
max-width: 350px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.van-popup) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
BIN
app/static/images/reset.png
Normal file
BIN
app/static/images/reset.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
@ -38,7 +38,8 @@
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-pdf-embed": "^2.1.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-signature-pad": "^3.0.2"
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-puzzle-vcode": "1.1.6-nuxt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/carbon": "^1.2.5",
|
||||
|
@ -77,6 +77,9 @@ importers:
|
||||
vue-signature-pad:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(vue@3.5.13(typescript@5.7.3))
|
||||
vue3-puzzle-vcode:
|
||||
specifier: 1.1.6-nuxt
|
||||
version: 1.1.6-nuxt
|
||||
devDependencies:
|
||||
'@iconify-json/carbon':
|
||||
specifier: ^1.2.5
|
||||
@ -809,8 +812,8 @@ packages:
|
||||
resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@11.1.1':
|
||||
resolution: {integrity: sha512-2kGiWoXaeV8HZlhU/Nml12oTbhv7j2ufsJ5vQaa0VTjzUmZVdd/nmKFRAOJ/FtjO90Qba5AnZDwsrY7ZND5udA==}
|
||||
'@intlify/shared@11.1.2':
|
||||
resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.3':
|
||||
@ -4618,6 +4621,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue3-puzzle-vcode@1.1.6-nuxt:
|
||||
resolution: {integrity: sha512-V3DrPIYznxko8jBAtZtmsNPw9QmkPnFicQ0p9B192vC3ncRv4IDazhLC7D/cY/OGq0OeqXmk2DiOcBR7dyt8GQ==}
|
||||
|
||||
vue@3.5.13:
|
||||
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
|
||||
peerDependencies:
|
||||
@ -5342,14 +5348,14 @@ snapshots:
|
||||
|
||||
'@intlify/shared@11.0.0-rc.1': {}
|
||||
|
||||
'@intlify/shared@11.1.1': {}
|
||||
'@intlify/shared@11.1.2': {}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.3(@vue/compiler-dom@3.5.13)(eslint@9.20.1(jiti@2.4.2))(rollup@4.34.6)(typescript@5.7.3)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.20.1(jiti@2.4.2))
|
||||
'@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))
|
||||
'@intlify/shared': 11.1.1
|
||||
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.1)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
|
||||
'@intlify/shared': 11.1.2
|
||||
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
|
||||
'@rollup/pluginutils': 5.1.4(rollup@4.34.6)
|
||||
'@typescript-eslint/scope-manager': 8.24.0
|
||||
'@typescript-eslint/typescript-estree': 8.24.0(typescript@5.7.3)
|
||||
@ -5373,11 +5379,11 @@ snapshots:
|
||||
|
||||
'@intlify/utils@0.13.0': {}
|
||||
|
||||
'@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.1)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))':
|
||||
'@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.8
|
||||
optionalDependencies:
|
||||
'@intlify/shared': 11.1.1
|
||||
'@intlify/shared': 11.1.2
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.3))
|
||||
@ -9896,6 +9902,8 @@ snapshots:
|
||||
signature_pad: 3.0.0-beta.4
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
vue3-puzzle-vcode@1.1.6-nuxt: {}
|
||||
|
||||
vue@3.5.13(typescript@5.7.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
|
Loading…
Reference in New Issue
Block a user