Files
carrotskin/src/components/SliderCaptcha.tsx
lan 2124790c8d
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
feat: add Docker support and texture deletion functionality
Add Docker configuration with standalone output mode for containerized
deployment. Implement texture deletion API with proper error handling
and user feedback. Fix skin viewer sizing issues by using explicit
dimensions and removing conflicting layout properties. Add captcha
ID parameter to registration flow. Improve profile page UX including
Yggdrasil password reset display and character card editing controls.
2026-02-24 11:09:37 +08:00

486 lines
21 KiB
TypeScript
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.
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<SliderCaptchaProps> = ({ 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<string>('');
// 拼图Base64字符串从后端获取
const [puzzleImage, setPuzzleImage] = useState<string>('');
// 验证码进程ID从后端获取用于验证时标识当前验证码
const [processId, setProcessId] = useState<string>('');
// 验证结果false-未验证/验证失败true-验证成功,'error'-请求错误
const [verifyResult, setVerifyResult] = useState<boolean | string>(false);
// 提示信息:显示后端返回的提示或默认提示
const [msg, setMsg] = useState<string>('拖动滑块完成拼图');
const sliderRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(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 <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />;
}
// 验证成功时,无论其他状态如何,都显示对勾图标
if (isVerified) {
return <Check className="w-5 h-5 text-green-600" />;
}
// 验证失败或错误时显示叉号图标
if (showError || verifyResult === 'error') {
return <X className="w-5 h-5 text-red-600" />;
}
// 默认显示蓝色圆点
return <div className="w-3 h-3 bg-blue-500 rounded-full" />;
};
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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-auto transform transition-all duration-300 scale-100">
{/* 头部区域:显示标题和关闭按钮 */}
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
</div>
{/* 关闭按钮 */}
<button onClick={onClose} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="关闭">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* 显示验证码图片和滑块 */}
<div className="p-6">
<div className="relative">
{/* 背景图片容器尺寸300x200px与后端图片尺寸匹配 */}
<div className="relative bg-gray-200 rounded-lg w-[300px] h-[200px] mb-4 overflow-hidden mx-auto">
{backgroundImage && (
<img
src={backgroundImage}
alt="验证背景"
className="h-full w-full object-cover" // 图片填满容器
/>
)}
{/* 可移动拼图块 */}
{puzzleImage && (
<div
className={`absolute ${isDragging ? '' : 'transition-all duration-300'}`}
style={{
left: `${sliderPosition}px`, // 滑块x位置拼图左上角x坐标
top: `${puzzleY}px`, // 拼图y位置从后端获取拼图左上角y坐标
zIndex: 10,
}}
>
<img
src={puzzleImage}
alt="拼图块"
className={`${isVerified ? 'opacity-100' : 'opacity-90'}`}
style={{
filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
}}
/>
</div>
)}
</div>
{/* 提示文本 */}
<p className="text-sm text-gray-600 mb-4 text-center">{getStatusText()}</p>
</div>
{/* 滑动轨道 */}
<div className="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}>
{/* 进度条 */}
<div
className={`absolute left-0 top-0 h-full ${isDragging ? '' : 'transition-all duration-200 ease-out'} ${getProgressColor()}`}
style={{
width: `${sliderPosition + SLIDER_WIDTH}px`,
transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)',
transformOrigin: 'bottom'
}}
/>
{/* 滑块按钮 */}
<div
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center ${isDragging ? '' : 'transition-all duration-200 ease-out'} select-none ${
isDragging ? 'scale-110 shadow-xl' : 'scale-100'
} ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }}
onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown}
onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart}
ref={sliderRef}
>
{getSliderIcon()}
</div>
{/* 轨道上的提示文字 */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className={`text-sm font-medium transition-all duration-300 ${
sliderPosition > 60 ? 'opacity-0 transform translate-x-4' : 'opacity-100 transform translate-x-0'
} ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
</div>
</div>
{/* 底部信息区域 */}
<div className="px-6 pb-6">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>: {attempts}</span>
<span className="flex items-center space-x-1">
<Shield className="w-3 h-3" />
<span></span>
</span>
</div>
</div>
</div>
</div>
);
};
export default SliderCaptcha;