feat(auth): 添加滑动验证码功能

- 在 auth API 中新增 userCaptcha 和 userCaptchaValidate 方法
- 在登录页面集成滑动验证码组件
- 实现验证码获取
This commit is contained in:
xingyy 2025-03-11 19:34:31 +08:00
parent 4041b45cca
commit 85523e8321
6 changed files with 421 additions and 20 deletions

View File

@ -23,3 +23,17 @@ export async function userUpdate(data) {
data 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
})
}

View 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>

View File

@ -1,15 +1,21 @@
<script setup> <script setup>
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Vcode from "vue3-puzzle-vcode";
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import countryCode from '../countryRegion/data/index.js' 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 {authStore} from "@/stores/auth/index.js";
import {message} from '@/components/x-message/useMessage.js' import {message} from '@/components/x-message/useMessage.js'
import {fddCheck} from "~/api/goods/index.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 {userInfo,token,selectedZone}= authStore()
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { locale } = useI18n() const { locale } = useI18n()
const imgs=ref([zu6020])
console.log('zu6020');
definePageMeta({ definePageMeta({
name: 'login', name: 'login',
i18n: 'login.title' i18n: 'login.title'
@ -79,21 +85,44 @@ onMounted(()=>{
selectedCountry.value=route.query.countryName || defaultCountry.name selectedCountry.value=route.query.countryName || defaultCountry.name
}) })
const vanSwipeRef=ref(null) const vanSwipeRef=ref(null)
const captcha=ref({
"nonceStr": "397b5e08168c31c4",
"blockX": 256
})
const captchaUrl=ref('')
const captchaVerifyUrl=ref('')
const blockY=ref(0)
const getCode =async () => { const getCode =async () => {
loadingRef.value.loading1=true
const res=await senCode({ const res=await userCaptcha({
telNum:phoneNum.value, "canvasWidth": 320, //(canvasWidth41canvasWidth-10/2 - 1> blockWidth)
zone:selectedZone.value "canvasHeight": 191, //(canvasHeight26 canvasHeight - blockHeight > 11
"blockWidth": 50, //(blockWidth14)
"blockHeight": 50, //(blockHeight14)
// "blockRadius": 25, //
"place": 0 //0URL1 0
}) })
loadingRef.value.loading1=false
if (res.status===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 // loadingRef.value.loading1=true
vanSwipeRef.value?.swipeTo(pane.value) // const res=await senCode({
// telNum:phoneNum.value,
// zone:selectedZone.value
// })
// loadingRef.value.loading1=false
// if (res.status===0){
startCountdown();
// }
// pane.value = 1
// vanSwipeRef.value?.swipeTo(pane.value)
// startCountdown();
} }
const goBack = () => { const goBack = () => {
code.value = '' code.value = ''
@ -146,6 +175,27 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', () => {}) 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> </script>
<template> <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"> <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> {{ $t('login.agreement') }}<span class="text-#3454AF " @click="$router.push('/privacyPolicy')">{{ $t('login.privacyPolicy') }}</span>
</div> </div>
<van-popup v-model:show="isShow" round>
<YourPuzzleComponent
:show="true"
:blockY="blockY"
:bgImageUrl="captchaUrl"
:sliderImageUrl="captchaVerifyUrl"
@leave="onLeave"
/>
</van-popup>
</div> </div>
</template> </template>
@ -238,4 +298,15 @@ onUnmounted(() => {
width: 41px; width: 41px;
height: 41px; height: 41px;
} }
.verify-popup-content {
width: 90vw;
max-width: 350px;
padding: 20px;
box-sizing: border-box;
}
:deep(.van-popup) {
background: transparent;
}
</style> </style>

BIN
app/static/images/reset.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -38,7 +38,8 @@
"vue-demi": "^0.14.10", "vue-demi": "^0.14.10",
"vue-pdf-embed": "^2.1.2", "vue-pdf-embed": "^2.1.2",
"vue-router": "^4.5.0", "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": { "devDependencies": {
"@iconify-json/carbon": "^1.2.5", "@iconify-json/carbon": "^1.2.5",

View File

@ -77,6 +77,9 @@ importers:
vue-signature-pad: vue-signature-pad:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(vue@3.5.13(typescript@5.7.3)) 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: devDependencies:
'@iconify-json/carbon': '@iconify-json/carbon':
specifier: ^1.2.5 specifier: ^1.2.5
@ -809,8 +812,8 @@ packages:
resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==} resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
'@intlify/shared@11.1.1': '@intlify/shared@11.1.2':
resolution: {integrity: sha512-2kGiWoXaeV8HZlhU/Nml12oTbhv7j2ufsJ5vQaa0VTjzUmZVdd/nmKFRAOJ/FtjO90Qba5AnZDwsrY7ZND5udA==} resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
'@intlify/unplugin-vue-i18n@6.0.3': '@intlify/unplugin-vue-i18n@6.0.3':
@ -4618,6 +4621,9 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.2.0 vue: ^3.2.0
vue3-puzzle-vcode@1.1.6-nuxt:
resolution: {integrity: sha512-V3DrPIYznxko8jBAtZtmsNPw9QmkPnFicQ0p9B192vC3ncRv4IDazhLC7D/cY/OGq0OeqXmk2DiOcBR7dyt8GQ==}
vue@3.5.13: vue@3.5.13:
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
peerDependencies: peerDependencies:
@ -5342,14 +5348,14 @@ snapshots:
'@intlify/shared@11.0.0-rc.1': {} '@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))': '@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: dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.20.1(jiti@2.4.2)) '@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/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.3)))
'@intlify/shared': 11.1.1 '@intlify/shared': 11.1.2
'@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))
'@rollup/pluginutils': 5.1.4(rollup@4.34.6) '@rollup/pluginutils': 5.1.4(rollup@4.34.6)
'@typescript-eslint/scope-manager': 8.24.0 '@typescript-eslint/scope-manager': 8.24.0
'@typescript-eslint/typescript-estree': 8.24.0(typescript@5.7.3) '@typescript-eslint/typescript-estree': 8.24.0(typescript@5.7.3)
@ -5373,11 +5379,11 @@ snapshots:
'@intlify/utils@0.13.0': {} '@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: dependencies:
'@babel/parser': 7.26.8 '@babel/parser': 7.26.8
optionalDependencies: optionalDependencies:
'@intlify/shared': 11.1.1 '@intlify/shared': 11.1.2
'@vue/compiler-dom': 3.5.13 '@vue/compiler-dom': 3.5.13
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
vue-i18n: 10.0.5(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 signature_pad: 3.0.0-beta.4
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
vue3-puzzle-vcode@1.1.6-nuxt: {}
vue@3.5.13(typescript@5.7.3): vue@3.5.13(typescript@5.7.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.13 '@vue/compiler-dom': 3.5.13