This commit is contained in:
houjunxiang
2025-10-09 18:19:55 +08:00
parent f2ffc65094
commit 386f1e7466
1553 changed files with 284685 additions and 32820 deletions

View File

@@ -0,0 +1,557 @@
<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 sysApi from '@/nx/api/sys'
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 sysApi.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 sysApi.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>

View File

@@ -0,0 +1,17 @@
import CryptoJS from 'crypto-js';
/**
* 使用 AES-ECB 模式 + PKCS7 填充对字符串进行加密
* @param {string} word - 需要加密的明文
* @param {string} [keyWord='XwKsGlMcdPMEhR1B'] - 加密密钥,默认为 aj-captcha 默认密钥
* @returns {string} - 加密后的密文
*/
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
const key = CryptoJS.enc.Utf8.parse(keyWord);
const srcs = CryptoJS.enc.Utf8.parse(word);
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}

View File

@@ -0,0 +1,227 @@
/**
* 验证码组件工具函数
*/
/**
* 生成随机整数
* @param {number} min 最小值
* @param {number} max 最大值
* @returns {number} 随机整数
*/
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 生成随机颜色
* @returns {string} 十六进制颜色值
*/
export function randomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* 生成随机字符串
* @param {number} length 字符串长度
* @param {string} chars 字符集
* @returns {string} 随机字符串
*/
export function randomString(length = 4, chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678') {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 计算两点距离
* @param {number} x1 点1 x坐标
* @param {number} y1 点1 y坐标
* @param {number} x2 点2 x坐标
* @param {number} y2 点2 y坐标
* @returns {number} 距离
*/
export function getDistance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
* 判断点是否在矩形区域内
* @param {number} x 点x坐标
* @param {number} y 点y坐标
* @param {Object} rect 矩形对象 {x, y, width, height}
* @returns {boolean} 是否在区域内
*/
export function isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
/**
* 生成验证码图片拼图
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} x x坐标
* @param {number} y y坐标
* @param {number} r 圆角半径
* @param {number} PI 圆周率
*/
export function drawPath(ctx, x, y, r, PI) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + r / 2, y - r + 2, r, 0, 2 * PI);
ctx.lineTo(x + r, y);
ctx.arc(x + r + r / 2, y + r / 2, r, 1.5 * PI, 0.5 * PI);
ctx.lineTo(x + r, y + r);
ctx.lineTo(x, y + r);
ctx.arc(x + r / 2, y + r + r / 2, r, PI, 0);
ctx.closePath();
}
/**
* 获取图片像素数据
* @param {string} imgSrc 图片源
* @returns {Promise<ImageData>} 图片像素数据
*/
export function getImageData(imgSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
};
img.onerror = reject;
img.crossOrigin = 'anonymous';
img.src = imgSrc;
});
}
/**
* 创建干扰线
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} lineCount 干扰线数量
*/
export function drawInterferenceLines(ctx, width, height, lineCount = 8) {
for (let i = 0; i < lineCount; i++) {
ctx.strokeStyle = randomColor();
ctx.lineWidth = randomInt(1, 3);
ctx.beginPath();
ctx.moveTo(randomInt(0, width), randomInt(0, height));
ctx.lineTo(randomInt(0, width), randomInt(0, height));
ctx.stroke();
}
}
/**
* 创建干扰点
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} pointCount 干扰点数量
*/
export function drawInterferencePoints(ctx, width, height, pointCount = 100) {
for (let i = 0; i < pointCount; i++) {
ctx.fillStyle = randomColor();
ctx.beginPath();
ctx.arc(randomInt(0, width), randomInt(0, height), randomInt(1, 2), 0, 2 * Math.PI);
ctx.fill();
}
}
/**
* 节流函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 节流后的函数
*/
export function throttle(func, delay) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, args);
timer = null;
}, delay);
}
};
}
/**
* 防抖函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 防抖后的函数
*/
export function debounce(func, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
/**
* 重置画布
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
*/
export function resetCanvas(ctx, width, height) {
ctx.clearRect(0, 0, width, height);
}
/**
* 获取元素的绝对位置
* @param {Element} element DOM元素
* @returns {Object} {left, top}
*/
export function getElementPosition(element) {
let left = 0;
let top = 0;
while (element) {
left += element.offsetLeft;
top += element.offsetTop;
element = element.offsetParent;
}
return { left, top };
}
/**
* 移动端触摸事件坐标获取
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getTouchPosition(e) {
const touch = e.touches && e.touches.length > 0 ? e.touches[0] : e.changedTouches[0];
return {
x: touch.clientX,
y: touch.clientY
};
}
/**
* 获取鼠标或触摸位置
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getEventPosition(e) {
if (e.touches) {
return getTouchPosition(e);
}
return {
x: e.clientX,
y: e.clientY
};
}
export { aesEncrypt } from './aes';