Files
zgty-mas-m/components/n-verify/n-verify.vue
2025-11-19 11:02:11 +08:00

567 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<view v-if="visible" class="s-verify-mask" @touchmove.stop.prevent>
<view class="s-verify-wrapper" @tap.stop>
<view class="s-verify-header">
<text class="s-verify-title">安全验证</text>
<!-- <view class="s-verify-close" @tap="handleClose">×</view> -->
</view>
<view class="s-verify-body">
<view class="s-verify-image" :style="imageStyle">
<image v-if="backgroundImage" class="s-verify-image-bg" :src="backgroundImage" mode="aspectFill" />
<image
v-if="showBlock"
class="s-verify-image-block"
:style="blockStyle"
:src="blockImage"
mode="widthFix"
/>
<view class="s-verify-refresh" @tap="refreshCaptcha">
<text></text>
</view>
<view v-if="loading" class="s-verify-loading">加载中...</view>
</view>
<view v-if="tipMessage" :class="['s-verify-tip', tipClass]">
{{ tipMessage }}
</view>
<view
class="s-verify-slider"
ref="sliderRef"
@touchstart.stop.prevent="onDragStart"
@touchmove.stop.prevent="onDragMove"
@touchend.stop.prevent="onDragEnd"
@mousedown.stop="onMouseDown"
>
<view class="s-verify-slider-track" />
<view class="s-verify-slider-fill" :style="sliderFillStyle" />
<view
class="s-verify-slider-handle"
:class="{
's-verify-slider-success': success,
's-verify-slider-fail': failAnimate
}"
:style="sliderHandleStyle"
>
<text class="s-verify-handle-icon">{{ success ? '✔' : '' }}</text>
</view>
<text class="s-verify-slider-text">{{ sliderHint }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import nx from '@/nx'
import { aesEncrypt } from './utils/aes'
const props = defineProps({
captchaType: {
type: String,
default: 'blockPuzzle'
},
mode: {
type: String,
default: 'pop'
}
})
const emits = defineEmits(['success', 'error'])
const visible = ref(false)
const loading = ref(false)
const success = ref(false)
const failAnimate = ref(false)
const verifying = ref(false)
const tipMessage = ref('')
const sliderRef = ref(null)
const sliderWidth = 310
const sliderHeight = 40
const blockWidth = Math.floor((sliderWidth * 47) / 310)
const imageHeight = 155
const dragState = reactive({
startClientX: 0,
baseOffset: 0,
isDragging: false
})
const captchaState = reactive({
originalImageBase64: '',
jigsawImageBase64: '',
token: '',
secretKey: ''
})
const handleOffset = ref(0)
const maxOffset = computed(() => Math.max(sliderWidth - blockWidth, 0))
const showBlock = computed(() => props.captchaType === 'blockPuzzle' && blockImage.value)
const backgroundImage = computed(() =>
captchaState.originalImageBase64 ? `data:image/png;base64,${captchaState.originalImageBase64}` : ''
)
const blockImage = computed(() =>
captchaState.jigsawImageBase64 ? `data:image/png;base64,${captchaState.jigsawImageBase64}` : ''
)
const imageStyle = computed(() => ({
width: `${sliderWidth}px`,
height: `${imageHeight}px`
}))
const blockStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${imageHeight}px`,
transform: `translateX(${handleOffset.value}px)`
}))
const sliderHandleStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${sliderHeight}px`,
transform: `translateX(${handleOffset.value}px)`
}))
const sliderFillStyle = computed(() => ({
width: `${handleOffset.value > 0 ? Math.min(handleOffset.value + blockWidth, sliderWidth) : 0}px`
}))
const sliderHint = computed(() => {
if (success.value) {
return '验证通过'
}
if (loading.value) {
return '验证码加载中'
}
return '请按住滑块,拖动完成拼图'
})
const tipClass = computed(() => {
if (success.value) return 's-verify-tip-success'
if (failAnimate.value) return 's-verify-tip-error'
return ''
})
let closeTimer = null
let mouseMoveListener = null
let mouseUpListener = null
function show() {
if (visible.value) return
visible.value = true
resetState()
nextTick(() => {
loadCaptcha()
})
}
function handleClose() {
visible.value = false
cleanupMouseListeners()
clearCloseTimer()
}
function refreshCaptcha() {
if (loading.value) return
resetSlider()
loadCaptcha()
}
function resetSlider() {
handleOffset.value = 0
dragState.baseOffset = 0
dragState.startClientX = 0
dragState.isDragging = false
success.value = false
failAnimate.value = false
tipMessage.value = ''
verifying.value = false
}
function resetState() {
captchaState.originalImageBase64 = ''
captchaState.jigsawImageBase64 = ''
captchaState.token = ''
captchaState.secretKey = ''
resetSlider()
}
async function loadCaptcha() {
loading.value = true
try {
const res = await nx.$api.sys.getCaptchaCode({ captchaType: props.captchaType })
if (res && (res.repCode === '0000' || res.code === 0)) {
const data = res.repData || res.data || {}
captchaState.originalImageBase64 = data.originalImageBase64 || ''
captchaState.jigsawImageBase64 = data.jigsawImageBase64 || ''
captchaState.token = data.token || ''
captchaState.secretKey = data.secretKey || ''
} else {
tipMessage.value = res?.repMsg || res?.msg || '验证码获取失败,请重试'
emits('error', res)
}
} catch (error) {
console.error('captcha load error', error)
tipMessage.value = '验证码加载异常,请稍后再试'
emits('error', error)
} finally {
loading.value = false
}
}
function onDragStart(event) {
if (loading.value || success.value) return
dragState.isDragging = true
dragState.startClientX = getClientX(event)
dragState.baseOffset = handleOffset.value
failAnimate.value = false
tipMessage.value = ''
}
function onDragMove(event) {
if (!dragState.isDragging) return
const currentX = getClientX(event)
const delta = currentX - dragState.startClientX
let next = dragState.baseOffset + delta
if (next < 0) next = 0
if (next > maxOffset.value) next = maxOffset.value
handleOffset.value = next
}
function onDragEnd() {
if (!dragState.isDragging) return
dragState.isDragging = false
submitVerification()
}
function onMouseDown(event) {
if (event?.button !== 0) return
if (typeof window !== 'undefined') {
mouseMoveListener = ev => {
onDragMove(ev)
ev.preventDefault()
}
mouseUpListener = ev => {
cleanupMouseListeners()
onDragEnd(ev)
}
window.addEventListener('mousemove', mouseMoveListener)
window.addEventListener('mouseup', mouseUpListener)
}
onDragStart(event)
}
function cleanupMouseListeners() {
if (typeof window === 'undefined') return
if (mouseMoveListener) {
window.removeEventListener('mousemove', mouseMoveListener)
mouseMoveListener = null
}
if (mouseUpListener) {
window.removeEventListener('mouseup', mouseUpListener)
mouseUpListener = null
}
}
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
async function submitVerification() {
if (verifying.value) return
verifying.value = true
failAnimate.value = false
const point = {
x: Math.round(handleOffset.value),
y: 5.0
}
try {
const payload = captchaState.secretKey
? aesEncrypt(JSON.stringify(point), captchaState.secretKey)
: JSON.stringify(point)
const res = await nx.$api.sys.verifyCaptcha({
captchaType: props.captchaType,
token: captchaState.token,
pointJson: payload
})
if (res && (res.repCode === '0000' || res.code === 0)) {
success.value = true
tipMessage.value = '验证成功'
const captchaVerification = captchaState.secretKey
? aesEncrypt(`${captchaState.token}---${JSON.stringify(point)}`, captchaState.secretKey)
: `${captchaState.token}---${JSON.stringify(point)}`
emits('success', {
captchaVerification,
token: captchaState.token,
point
})
clearCloseTimer()
closeTimer = setTimeout(() => {
handleClose()
}, 800)
} else {
handleFail(res?.repMsg || res?.msg || '验证失败,请重试')
}
} catch (error) {
console.error('captcha verify error', error)
handleFail('网络异常,请重试')
} finally {
verifying.value = false
}
}
function handleFail(message) {
failAnimate.value = true
tipMessage.value = message
emits('error', message)
setTimeout(() => {
failAnimate.value = false
}, 400)
setTimeout(() => {
refreshCaptcha()
}, 500)
}
function getClientX(event) {
if (event?.touches && event.touches.length) {
return event.touches[0].clientX
}
if (event?.changedTouches && event.changedTouches.length) {
return event.changedTouches[0].clientX
}
return event?.clientX || 0
}
onBeforeUnmount(() => {
cleanupMouseListeners()
clearCloseTimer()
})
defineExpose({
show,
close: handleClose,
refresh: refreshCaptcha
})
</script>
<style lang="scss" scoped>
.s-verify-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.s-verify-wrapper {
width: 340px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.s-verify-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f2f3f5;
}
.s-verify-title {
font-size: 16px;
font-weight: 600;
color: #1f2d3d;
}
.s-verify-close {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
color: #999999;
font-size: 18px;
}
.s-verify-body {
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.s-verify-image {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
}
.s-verify-image-bg {
width: 100%;
height: 100%;
display: block;
}
.s-verify-image-block {
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.s-verify-refresh {
position: absolute;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.s-verify-loading {
position: absolute;
top: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.65);
font-size: 14px;
color: #666666;
}
.s-verify-tip {
width: 100%;
text-align: center;
font-size: 14px;
padding: 6px 0;
border-radius: 6px;
}
.s-verify-tip-success {
color: #52c41a;
background: rgba(82, 196, 26, 0.12);
}
.s-verify-tip-error {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.12);
}
.s-verify-slider {
position: relative;
width: 310px;
height: 40px;
border-radius: 20px;
background: #f2f3f5;
overflow: hidden;
}
.s-verify-slider-track {
width: 100%;
height: 100%;
border-radius: 20px;
background: #f2f3f5;
}
.s-verify-slider-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--ui-BG-Main, #409eff);
border-radius: 20px;
transition: width 0.05s linear;
}
.s-verify-slider-handle {
position: absolute;
top: 0;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 6px 14px rgba(25, 87, 170, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: #1f2d3d;
transition: transform 0.05s linear;
}
.s-verify-slider-success {
background: #52c41a;
color: #ffffff;
}
.s-verify-slider-fail {
animation: s-verify-shake 0.3s;
}
.s-verify-handle-icon {
font-size: 22px;
font-weight: 600;
}
.s-verify-slider-text {
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 14px;
}
@keyframes s-verify-shake {
0% {
transform: translateX(-4px);
}
25% {
transform: translateX(4px);
}
50% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
</style>