import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Shield, X, Check } from 'lucide-react'; import axios from 'axios'; import { API_BASE_URL } from '@/lib/api'; /** * 滑块验证码组件属性接口定义 * @interface SliderCaptchaProps * @property {function} onVerify - 验证结果回调函数,参数为验证是否成功 * @property {function} onClose - 关闭验证码组件的回调函数 */ interface SliderCaptchaProps { onVerify: (success: boolean) => void; onClose: () => void; } // 轨道宽度(与背景图宽度一致) const TRACK_WIDTH = 300; // 滑块按钮宽度 const SLIDER_WIDTH = 50; // 背景图宽度(与后端返回的背景图尺寸匹配) // const CANVAS_WIDTH = 300; /** * 滑块验证码组件 * 功能:通过拖拽滑块完成拼图验证,与后端交互获取验证码资源和验证结果 * 特点: * - 支持鼠标和触摸事件,适配PC和移动端 * - 与后端接口交互,获取背景图、拼图和验证结果 * - 包含验证状态反馈和错误处理 * @param {SliderCaptchaProps} props - 组件属性 * @returns {JSX.Element} 滑块验证码组件JSX元素 */ export const SliderCaptcha: React.FC = ({ onVerify, onClose }) => { // 拖拽状态:是否正在拖拽滑块 const [isDragging, setIsDragging] = useState(false); // 滑块当前位置(x坐标) const [sliderPosition, setSliderPosition] = useState(0); // 拼图y坐标(从后端获取) const [puzzleY, setPuzzleY] = useState(0); // 验证状态:是否验证成功 const [isVerified, setIsVerified] = useState(false); // 加载状态:是否正在加载资源或验证中 const [isLoading, setIsLoading] = useState(false); // 尝试次数:记录验证失败的次数 const [attempts, setAttempts] = useState(0); // 错误显示状态:是否显示验证错误提示 const [showError, setShowError] = useState(false); // 拖拽偏移量:鼠标/触摸点与滑块中心的偏移,用于精准计算滑块位置 const [dragOffset, setDragOffset] = useState(0); // 背景图Base64字符串(从后端获取) const [backgroundImage, setBackgroundImage] = useState(''); // 拼图Base64字符串(从后端获取) const [puzzleImage, setPuzzleImage] = useState(''); // 验证码进程ID(从后端获取,用于验证时标识当前验证码) const [processId, setProcessId] = useState(''); // 验证结果:false-未验证/验证失败,true-验证成功,'error'-请求错误 const [verifyResult, setVerifyResult] = useState(false); // 提示信息:显示后端返回的提示或默认提示 const [msg, setMsg] = useState('拖动滑块完成拼图'); const sliderRef = useRef(null); const trackRef = useRef(null); /** * 获取验证码资源(背景图、拼图、位置信息等) * 从后端接口请求验证码所需的资源数据,包括背景图、拼图的Base64编码, * 拼图的y坐标和进程ID,并初始化拼图的x坐标 */ const fetchCaptchaResources = useCallback(async () => { try { // 开始加载,设置加载状态 setIsLoading(true); // 请求验证码资源接口 const response = await axios.get(`${API_BASE_URL}/captcha/generate`, { withCredentials: true // 关键:允许跨域携带凭证 }); const { code, msg: resMsg, data } = response.data; const { masterImage, tileImage, captchaId, y } = data; // 后端返回成功状态(code=200) if (code === 200) { // 设置背景图 setBackgroundImage(masterImage); // 设置拼图图片 setPuzzleImage(tileImage); // 设置拼图y坐标(从后端获取,以背景图左上角为原点) setPuzzleY(y); // 设置进程ID(用于后续验证) setProcessId(captchaId); // 随机生成拼图x坐标(确保拼图在背景图内) // setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50); // 保存后端返回的提示信息 setMsg(resMsg); // 结束加载状态 setIsLoading(false); return; } // 后端返回失败状态(非200) setMsg(resMsg || '生成验证码失败'); setVerifyResult('error'); setIsLoading(false); } catch (error) { // 捕获请求异常 const errMsg = '获取验证码资源失败: ' + (error as Error).message; console.error(errMsg); setMsg(errMsg); setVerifyResult('error'); setIsLoading(false); } }, []); /** * 组件挂载时自动获取验证码资源 * 依赖fetchCaptchaResources函数,确保函数变化时重新执行 */ useEffect(() => { fetchCaptchaResources(); }, [fetchCaptchaResources]); /** * 开始拖拽处理函数 * 记录初始拖拽位置和偏移量,设置拖拽状态 * @param {number} clientX - 鼠标/触摸点的x坐标 */ const handleStart = useCallback((clientX: number) => { if (isVerified || isLoading || verifyResult === 'error') return; setIsDragging(true); setShowError(false); const slider = sliderRef.current; if (slider) { const rect = slider.getBoundingClientRect(); setDragOffset(clientX - rect.left - SLIDER_WIDTH / 2); } }, [isVerified, isLoading, verifyResult]); /** * 拖拽移动处理函数 * 根据鼠标/触摸点的移动更新滑块位置,限制滑块在轨道范围内 * @param {number} clientX - 鼠标/触摸点的x坐标 */ const handleMove = useCallback((clientX: number) => { if (!isDragging || isVerified || isLoading || verifyResult === 'error') return; const track = trackRef.current; if (!track) return; const rect = track.getBoundingClientRect(); const x = clientX - rect.left - dragOffset; const maxPosition = TRACK_WIDTH - SLIDER_WIDTH; const newPosition = Math.max(0, Math.min(x, maxPosition)); setSliderPosition(newPosition); }, [isDragging, isVerified, isLoading, dragOffset, verifyResult]); /** * 结束拖拽处理函数 * 拖拽结束后向后端发送验证请求,处理验证结果 */ const handleEnd = useCallback(async () => { if (!isDragging || isVerified || isLoading || verifyResult === 'error') return; setIsDragging(false); setIsLoading(true); try { // 向后端发送验证请求,参数为滑块位置(x坐标)和进程ID // 使用sliderPosition作为dx值,这是拼图块左上角的位置 const response = await axios.post(`${API_BASE_URL}/captcha/verify`, { dx: sliderPosition, // 滑块位置(拼图左上角x坐标,以背景图左上角为原点) captchaId: processId // 验证码进程ID },{ withCredentials: true }); const { code, msg: resMsg, data } = response.data; // 保存后端返回的提示信息 setMsg(resMsg); // 根据后端返回的code判断验证结果 // 验证成功:code=200 if (code === 200) { // 增加尝试次数 setAttempts(prev => prev + 1); // 重置所有状态,确保验证成功状态的纯净性 setShowError(false); setVerifyResult(false); // 直接设置验证成功状态,不使用异步更新 setIsVerified(true); // 延迟1.2秒后调用验证成功回调 setTimeout(() => onVerify(true), 1200); } // 验证失败:code=400 else if (code === 400) { // 确保错误状态的正确性:验证失败显示红色 setVerifyResult(false); setShowError(true); setIsVerified(false); // 增加尝试次数 setAttempts(prev => prev + 1); // 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果 setTimeout(() => { setSliderPosition(0); setShowError(false); setVerifyResult(false); setIsVerified(false); }, 1500); } // 后端返回系统错误(500) else if (code === 500) { // 系统错误显示橙色 setVerifyResult('error'); setShowError(true); setIsVerified(false); // 增加尝试次数 setAttempts(prev => prev + 1); // 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果 setTimeout(() => { setSliderPosition(0); setShowError(false); setVerifyResult(false); setIsVerified(false); }, 1500); } } catch (error) { // 捕获验证请求异常 const errMsg = '验证请求失败: ' + (error as Error).message; console.error(errMsg); setMsg(errMsg); setVerifyResult('error'); setShowError(true); // 1.5秒后重置滑块位置并隐藏错误提示 setTimeout(() => { setSliderPosition(0); setShowError(false); }, 1500); } finally { // 无论成功失败,都结束加载状态 setIsLoading(false); } }, [isDragging, isVerified, isLoading, sliderPosition, processId, onVerify, verifyResult]); /** * 鼠标按下事件处理 * 阻止默认行为,调用开始拖拽函数 * @param {React.MouseEvent} e - 鼠标事件对象 */ const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); handleStart(e.clientX); }; /** * 鼠标移动事件处理 * 阻止默认行为,调用拖拽移动函数 * @param {MouseEvent} e - 鼠标事件对象 */ const handleMouseMove = useCallback((e: MouseEvent) => { e.preventDefault(); handleMove(e.clientX); }, [handleMove]); /** * 鼠标释放事件处理 * 阻止默认行为,调用结束拖拽函数 * @param {MouseEvent} e - 鼠标事件对象 */ const handleMouseUp = useCallback((e: MouseEvent) => { e.preventDefault(); handleEnd(); }, [handleEnd]); /** * 触摸开始事件处理 * 阻止默认行为,调用开始拖拽函数(适配移动端) * @param {React.TouchEvent} e - 触摸事件对象 */ const handleTouchStart = (e: React.TouchEvent) => { e.preventDefault(); handleStart(e.touches[0].clientX); }; /** * 触摸移动事件处理 * 阻止默认行为,调用拖拽移动函数(适配移动端) * @param {TouchEvent} e - 触摸事件对象 */ const handleTouchMove = useCallback((e: TouchEvent) => { e.preventDefault(); handleMove(e.touches[0].clientX); }, [handleMove]); /** * 触摸结束事件处理 * 阻止默认行为,调用结束拖拽函数(适配移动端) * @param {TouchEvent} e - 触摸事件对象 */ const handleTouchEnd = useCallback((e: TouchEvent) => { e.preventDefault(); handleEnd(); }, [handleEnd]); /** * 拖拽状态变化时绑定/解绑全局事件 * 当开始拖拽时,为document绑定鼠标和触摸移动/结束事件; * 当结束拖拽时,移除这些事件监听 */ useEffect(() => { if (isDragging) { // 绑定鼠标事件 document.addEventListener('mousemove', handleMouseMove, { passive: false }); document.addEventListener('mouseup', handleMouseUp, { passive: false }); // 绑定触摸事件 document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleTouchEnd, { passive: false }); // 组件卸载或拖拽状态结束时,移除事件监听 return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); }; } }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); /** * 获取滑块显示的图标 * 根据不同状态(加载中、已验证、错误、默认)返回不同图标 * @returns {JSX.Element} 滑块图标 */ const getSliderIcon = () => { if (isLoading) { // 加载中显示旋转动画 return
; } // 验证成功时,无论其他状态如何,都显示对勾图标 if (isVerified) { return ; } // 验证失败或错误时显示叉号图标 if (showError || verifyResult === 'error') { return ; } // 默认显示蓝色圆点 return
; }; const getStatusText = () => { if (isVerified) { // 验证成功时优先显示成功消息 return msg; } if (verifyResult === 'error' || showError) { // 错误或验证失败时显示后端返回的消息 return msg; } // 默认显示拖拽提示 return '拖动滑块完成拼图'; }; const getStatusColor = () => { if (isVerified) return 'text-green-700'; if (verifyResult === 'error') return 'text-orange-700'; if (showError) return 'text-red-700'; return 'text-gray-600'; }; const getProgressColor = () => { // 验证成功时,无论其他状态如何,都显示绿色渐变 if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500'; // 系统错误(后端返回400/500)显示橙色渐变 if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500'; // 验证失败(后端返回200但data=false)显示红色渐变 if (showError && verifyResult !== 'error') return 'bg-gradient-to-r from-red-400 to-red-500'; // 默认显示蓝色渐变 return 'bg-gradient-to-r from-blue-400 to-blue-500'; }; return (
{/* 头部区域:显示标题和关闭按钮 */}

安全验证

{/* 关闭按钮 */}
{/* 显示验证码图片和滑块 */}
{/* 背景图片容器:尺寸300x200px,与后端图片尺寸匹配 */}
{backgroundImage && ( 验证背景 )} {/* 可移动拼图块 */} {puzzleImage && (
拼图块
)}
{/* 提示文本 */}

{getStatusText()}

{/* 滑动轨道 */}
{/* 进度条 */}
{/* 滑块按钮 */}
{getSliderIcon()}
{/* 轨道上的提示文字 */}
60 ? 'opacity-0 transform translate-x-4' : 'opacity-100 transform translate-x-0' } ${getStatusColor()}`}> {getStatusText()}
{/* 底部信息区域 */}
尝试次数: {attempts} 安全验证
); }; export default SliderCaptcha;