Files
zgty-mas-m/sheep/components/s-verify/s-verify.vue
2025-09-30 00:08:23 +08:00

570 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 AuthUtil from '@/sheep/api/system/auth';
import { aesEncrypt } from '@/sheep/components/s-verify/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 AuthUtil.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 AuthUtil.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;
inset: 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;
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;
inset: 0;
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 {
position: absolute;
inset: 0;
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;
inset: 0;
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>