diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index ca91280..72b6cdd 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -7,6 +7,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; import { useAuth } from '@/contexts/AuthContext'; import { errorManager } from '@/components/ErrorNotification'; +import SliderCaptcha from '@/components/SliderCaptcha'; export default function AuthPage() { const [isLoginMode, setIsLoginMode] = useState(true); @@ -27,6 +28,9 @@ export default function AuthPage() { const [authError, setAuthError] = useState(''); const [isSendingCode, setIsSendingCode] = useState(false); const [codeTimer, setCodeTimer] = useState(0); + const [showCaptcha, setShowCaptcha] = useState(false); + const [isCaptchaVerified, setIsCaptchaVerified] = useState(false); + const [captchaId, setCaptchaId] = useState(); const { login, register } = useAuth(); const router = useRouter(); @@ -161,6 +165,39 @@ export default function AuthPage() { } }; + const handleCaptchaVerify = (success: boolean) => { + if (success) { + setIsCaptchaVerified(true); + setShowCaptcha(false); + // 验证码验证成功后,继续注册流程 + handleRegisterAfterCaptcha(); + } else { + setIsCaptchaVerified(false); + setShowCaptcha(false); + errorManager.showError('验证码验证失败,请重试'); + } + }; + + const handleRegisterAfterCaptcha = async () => { + setIsLoading(true); + setAuthError(''); + + try { + await register(formData.username, formData.email, formData.password, formData.verificationCode, captchaId); + errorManager.showSuccess('注册成功!欢迎加入CarrotSkin!'); + router.push('/'); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试'; + setAuthError(errorMessage); + errorManager.showError(errorMessage); + // 注册失败时重置验证码状态 + setIsCaptchaVerified(false); + } finally { + setIsLoading(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -192,21 +229,14 @@ export default function AuthPage() { } else { if (!validateRegisterForm()) return; - setIsLoading(true); - setAuthError(''); - - try { - await register(formData.username, formData.email, formData.password, formData.verificationCode); - errorManager.showSuccess('注册成功!欢迎加入CarrotSkin!'); - router.push('/'); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试'; - setAuthError(errorMessage); - errorManager.showError(errorMessage); - } finally { - setIsLoading(false); + // 如果验证码还未验证,显示滑动验证码 + if (!isCaptchaVerified) { + setShowCaptcha(true); + return; } + + // 如果验证码已验证,直接进行注册 + handleRegisterAfterCaptcha(); } }; @@ -727,6 +757,15 @@ export default function AuthPage() { + + {/* Slider Captcha Component */} + {showCaptcha && ( + setShowCaptcha(false)} + /> + )} + ); } diff --git a/src/components/SliderCaptcha.tsx b/src/components/SliderCaptcha.tsx new file mode 100644 index 0000000..51eb400 --- /dev/null +++ b/src/components/SliderCaptcha.tsx @@ -0,0 +1,467 @@ +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, captcha_id, mBase64, tBase64, y } = response.data; + + // 后端返回成功状态(code=200) + if (code === 200) { + // 设置背景图 + setBackgroundImage(mBase64); + // 设置拼图图片 + setPuzzleImage(tBase64); + // 设置拼图y坐标(从后端获取,以背景图左上角为原点) + setPuzzleY(y); + // 设置进程ID(用于后续验证) + setProcessId(captcha_id); + // 随机生成拼图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 + const response = await axios.post(`${API_BASE_URL}/captcha/verify`, { + dx: sliderPosition, // 滑块位置(拼图左上角x坐标,以背景图左上角为原点) + captcha_id: processId // 验证码进程ID + },{ withCredentials: true }); + + const { code, msg: resMsg, data } = response.data; + // 保存后端返回的提示信息 + setMsg(resMsg); + + // 后端返回成功 (code=200) + if (code === 200) { + // 验证成功(data=true) + if (data === true) { + setIsVerified(true); + setVerifyResult(true); + // 延迟1.2秒后调用验证成功回调 + setTimeout(() => onVerify(true), 1200); + } + // 验证失败(data=false) + else { + setVerifyResult(false); + setShowError(true); + // 增加尝试次数 + setAttempts(prev => prev + 1); + // 1.5秒后重置滑块位置并隐藏错误提示 + setTimeout(() => { + setSliderPosition(0); + setShowError(false); + }, 1500); + } + } + // 后端返回参数错误(400)或系统错误(500) + else if (code === 400 || code === 500) { + setVerifyResult('error'); + setShowError(true); + // 增加尝试次数 + setAttempts(prev => prev + 1); + // 1.5秒后重置滑块位置并隐藏错误提示 + setTimeout(() => { + setSliderPosition(0); + setShowError(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) { + // 验证失败显示叉号图标 + return ; + } + // 默认显示蓝色圆点 + return
; + }; + + + const getStatusText = () => { + if (verifyResult === 'error' || showError || isVerified) { + // 错误、验证失败或成功时显示后端返回的消息 + return msg; + } + // 默认显示拖拽提示 + return '拖动滑块完成拼图'; + }; + + + const getStatusColor = () => { + if (verifyResult === 'error') return 'text-orange-700'; + if (isVerified) return 'text-green-700'; + if (showError) return 'text-red-700'; + return 'text-gray-600'; + }; + + + const getProgressColor = () => { + if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500'; + if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500'; + if (showError) 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 && !isVerified && ( +
+ 拼图块 +
+ )} +
+ {/* 提示文本 */} +

{getStatusText()}

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