Skip to content

Commit 22ed8af

Browse files
committed
feat: slider captcha
1 parent 61797e4 commit 22ed8af

4 files changed

Lines changed: 612 additions & 35 deletions

File tree

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,4 @@ export { default as Watermark } from './stateless/Watermark'
150150
// export { default as WaveBackground } from './stateless/WaveBackground'
151151
export { default as WordRotate } from './stateless/WordRotate'
152152
export { default as ScrollLayout } from './stateless/ScrollLayout'
153+
export { default as SliderCaptcha } from './stateless/SliderCaptcha'
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { useState, useEffect, useRef, useCallback } from 'react'
2+
import { RefreshCw, Zap } from 'lucide-react'
3+
import { CaptchaUtils, Difficulty } from './utils'
4+
5+
interface SliderCaptchaProps {
6+
onSuccess?: () => void
7+
onFail?: () => void
8+
onRefresh?: () => void
9+
difficulty?: Difficulty
10+
}
11+
12+
type CaptchaStatus = 'idle' | 'success' | 'fail'
13+
14+
const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onSuccess, onFail, onRefresh, difficulty = 'medium' }) => {
15+
const width = 300
16+
const height = 180
17+
const pieceSize = 50
18+
19+
const getDifficultySettings = () => {
20+
switch (difficulty) {
21+
case 'easy':
22+
return { maxShapes: 3, threshold: 20, tolerance: 5 }
23+
case 'hard':
24+
return { maxShapes: 6, threshold: 10, tolerance: 2 }
25+
default:
26+
return { maxShapes: 6, threshold: 15, tolerance: 3 }
27+
}
28+
}
29+
30+
const [bgUrl, setBgUrl] = useState('')
31+
const [pieceUrl, setPieceUrl] = useState('')
32+
const [targetX, setTargetX] = useState(0)
33+
const [targetY, setTargetY] = useState(0)
34+
const [sliderX, setSliderX] = useState(0)
35+
const [status, setStatus] = useState<CaptchaStatus>('idle')
36+
const [isDragging, setIsDragging] = useState(false)
37+
const [isRefreshing, setIsRefreshing] = useState(false)
38+
39+
const startXRef = useRef(0)
40+
const currentXRef = useRef(0)
41+
const callbacksRef = useRef({ onSuccess, onFail, onRefresh })
42+
43+
useEffect(() => {
44+
callbacksRef.current = { onSuccess, onFail, onRefresh }
45+
}, [onSuccess, onFail, onRefresh])
46+
47+
const refresh = useCallback(async () => {
48+
setIsRefreshing(true)
49+
setStatus('idle')
50+
setSliderX(0)
51+
currentXRef.current = 0
52+
53+
const settings = getDifficultySettings()
54+
const bg = CaptchaUtils.generateBgCanvas(width, height, difficulty)
55+
const tx = CaptchaUtils.random(100, width - pieceSize - 10)
56+
const ty = CaptchaUtils.random(20, height - pieceSize - 20)
57+
setTargetX(tx)
58+
setTargetY(ty)
59+
60+
const shapeType = CaptchaUtils.random(0, settings.maxShapes - 1)
61+
62+
const [finalBg, piece] = await Promise.all([
63+
CaptchaUtils.cutHole(bg, tx, ty, pieceSize, shapeType),
64+
CaptchaUtils.generatePiece(bg, tx, ty, pieceSize, shapeType),
65+
])
66+
67+
setBgUrl(finalBg)
68+
setPieceUrl(piece)
69+
setIsRefreshing(false)
70+
71+
if (callbacksRef.current.onRefresh) callbacksRef.current.onRefresh()
72+
}, [width, height, pieceSize, difficulty, getDifficultySettings])
73+
74+
useEffect(() => {
75+
// 使用 Promise.resolve 延迟调用,避免在 effect 中同步触发 setState
76+
Promise.resolve().then(() => {
77+
refresh()
78+
})
79+
// eslint-disable-next-line react-hooks/exhaustive-deps
80+
}, [])
81+
82+
const handleStart = useCallback(
83+
(clientX: number) => {
84+
if (status === 'success' || isRefreshing) return
85+
setIsDragging(true)
86+
startXRef.current = clientX - currentXRef.current
87+
},
88+
[status, isRefreshing]
89+
)
90+
91+
const handleMove = useCallback(
92+
(clientX: number) => {
93+
if (!isDragging) return
94+
95+
let x = clientX - startXRef.current
96+
if (x < 0) x = 0
97+
if (x > width - pieceSize) x = width - pieceSize
98+
99+
const settings = getDifficultySettings()
100+
const distToTarget = Math.abs(x - targetX)
101+
102+
if (distToTarget < settings.threshold) {
103+
x = targetX
104+
}
105+
106+
currentXRef.current = x
107+
setSliderX(x)
108+
109+
if (x === targetX) {
110+
setIsDragging(false)
111+
setStatus('success')
112+
if (callbacksRef.current.onSuccess) callbacksRef.current.onSuccess()
113+
}
114+
},
115+
[isDragging, width, pieceSize, targetX, getDifficultySettings]
116+
)
117+
118+
const handleEnd = useCallback(() => {
119+
if (!isDragging) return
120+
setIsDragging(false)
121+
122+
const settings = getDifficultySettings()
123+
const finalX = currentXRef.current
124+
125+
if (Math.abs(finalX - targetX) < settings.tolerance) {
126+
setStatus('success')
127+
if (callbacksRef.current.onSuccess) callbacksRef.current.onSuccess()
128+
} else {
129+
setStatus('fail')
130+
if (callbacksRef.current.onFail) callbacksRef.current.onFail()
131+
setTimeout(() => {
132+
setStatus('idle')
133+
setSliderX(0)
134+
currentXRef.current = 0
135+
}, 800)
136+
}
137+
}, [isDragging, targetX, getDifficultySettings])
138+
139+
useEffect(() => {
140+
const handleMouseMove = (e: MouseEvent) => handleMove(e.clientX)
141+
const handleMouseUp = () => handleEnd()
142+
const handleTouchMove = (e: TouchEvent) => {
143+
e.preventDefault()
144+
handleMove(e.touches[0].clientX)
145+
}
146+
const handleTouchEnd = () => handleEnd()
147+
148+
if (isDragging) {
149+
window.addEventListener('mousemove', handleMouseMove)
150+
window.addEventListener('mouseup', handleMouseUp)
151+
window.addEventListener('touchmove', handleTouchMove, { passive: false })
152+
window.addEventListener('touchend', handleTouchEnd)
153+
154+
return () => {
155+
window.removeEventListener('mousemove', handleMouseMove)
156+
window.removeEventListener('mouseup', handleMouseUp)
157+
window.removeEventListener('touchmove', handleTouchMove)
158+
window.removeEventListener('touchend', handleTouchEnd)
159+
}
160+
}
161+
}, [isDragging, handleMove, handleEnd])
162+
163+
const getStatusColor = () => {
164+
switch (status) {
165+
case 'success':
166+
return 'border-green-500 bg-green-50'
167+
case 'fail':
168+
return 'border-red-500 bg-red-50'
169+
default:
170+
return 'border-gray-300 bg-gray-50'
171+
}
172+
}
173+
174+
const getButtonColor = () => {
175+
switch (status) {
176+
case 'success':
177+
return 'bg-green-500'
178+
case 'fail':
179+
return 'bg-red-500'
180+
default:
181+
return 'bg-blue-500'
182+
}
183+
}
184+
185+
const getDifficultyLabel = () => {
186+
return difficulty.charAt(0).toUpperCase() + difficulty.slice(1)
187+
}
188+
189+
return (
190+
<div className="w-[340px] rounded-lg bg-white p-5 shadow-lg select-none">
191+
<div className="mb-4 flex items-center justify-between font-semibold text-gray-800">
192+
<div className="flex items-center gap-2">
193+
<span>Slider Captcha</span>
194+
<span className="flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-600">
195+
<Zap size={12} />
196+
{getDifficultyLabel()}
197+
</span>
198+
</div>
199+
<button
200+
onClick={refresh}
201+
disabled={isRefreshing}
202+
className="p-1 text-lg text-blue-500 transition-transform hover:rotate-180 disabled:opacity-50"
203+
>
204+
<RefreshCw className={isRefreshing ? 'animate-spin' : ''} size={18} />
205+
</button>
206+
</div>
207+
208+
<div className="relative mb-4 h-[180px] w-[300px] overflow-hidden rounded border border-gray-300 bg-gray-200">
209+
{bgUrl && (
210+
<img src={bgUrl} alt="Background" className="pointer-events-none block h-full w-full" draggable={false} />
211+
)}
212+
213+
{pieceUrl && (
214+
<img
215+
src={pieceUrl}
216+
alt="Puzzle piece"
217+
className={`absolute top-0 left-0 h-[50px] w-[50px] shadow-lg ${
218+
status === 'success' ? 'pointer-events-none opacity-100' : 'opacity-95 hover:opacity-100'
219+
} ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
220+
style={{
221+
transform: `translate3d(${sliderX}px, ${targetY}px, 0) rotate(${isDragging && status !== 'success' ? 2 : 0}deg)`,
222+
willChange: isDragging ? 'transform' : 'auto',
223+
transition: status === 'success' ? 'transform 0.3s ease-out' : 'none',
224+
filter: status === 'fail' ? 'brightness(0.8)' : 'brightness(1)',
225+
}}
226+
onMouseDown={(e) => {
227+
if (status === 'success') return
228+
e.preventDefault()
229+
handleStart(e.clientX)
230+
}}
231+
onTouchStart={(e) => {
232+
if (status === 'success') return
233+
e.preventDefault()
234+
handleStart(e.touches[0].clientX)
235+
}}
236+
draggable={false}
237+
/>
238+
)}
239+
</div>
240+
241+
<div
242+
className={`relative h-[40px] w-[300px] overflow-hidden rounded-full border text-center text-sm leading-[40px] transition-all duration-300 ${getStatusColor()}`}
243+
>
244+
{status === 'idle' && (
245+
<span className="block text-gray-500 transition-opacity duration-300">Drag to match the piece above</span>
246+
)}
247+
{status === 'success' && <span className="block animate-pulse font-bold text-green-600">✓ Verified!</span>}
248+
{status === 'fail' && <span className="block font-bold text-red-600">✗ Try again</span>}
249+
250+
<div
251+
className={`absolute top-0 left-0 z-10 flex h-[40px] w-[44px] items-center justify-center rounded-full text-white shadow-md ${
252+
status === 'success' ? 'pointer-events-none cursor-default' : isDragging ? 'cursor-grabbing' : 'cursor-grab'
253+
} ${getButtonColor()} ${status === 'success' ? 'animate-pulse' : ''}`}
254+
style={{
255+
transform: `translate3d(${sliderX}px, 0, 0)`,
256+
willChange: isDragging ? 'transform' : 'auto',
257+
transition: status === 'success' ? 'background-color 0.3s ease-out' : 'none',
258+
}}
259+
onMouseDown={(e) => {
260+
if (status === 'success') return
261+
e.preventDefault()
262+
handleStart(e.clientX)
263+
}}
264+
onTouchStart={(e) => {
265+
if (status === 'success') return
266+
e.preventDefault()
267+
handleStart(e.touches[0].clientX)
268+
}}
269+
>
270+
271+
</div>
272+
</div>
273+
</div>
274+
)
275+
}
276+
277+
export default SliderCaptcha

0 commit comments

Comments
 (0)