@@ -5,7 +5,6 @@ import type { LoginCaptchaChallenge, LoginCaptchaVerifyPayload } from '@/types'
55
66const { Text } = Typography
77const CAPTCHA_BASE_WIDTH = 320
8- const CAPTCHA_BASE_HEIGHT = 180
98
109type CaptchaStatus = 'idle' | 'dragging' | 'error' | 'success'
1110
@@ -28,23 +27,66 @@ export default function LoginCaptchaModal({
2827 onRefresh,
2928 onVerify,
3029} : LoginCaptchaModalProps ) {
31- const [ offsetX , setOffsetX ] = useState ( 0 )
3230 const [ status , setStatus ] = useState < CaptchaStatus > ( 'idle' )
3331 const [ viewportWidth , setViewportWidth ] = useState ( CAPTCHA_BASE_WIDTH )
3432
3533 const viewportRef = useRef < HTMLDivElement | null > ( null )
3634 const sliderRef = useRef < HTMLDivElement | null > ( null )
35+ const pieceRef = useRef < HTMLImageElement | null > ( null )
36+ const fillRef = useRef < HTMLDivElement | null > ( null )
37+ const handleRef = useRef < HTMLButtonElement | null > ( null )
3738 const pointerIdRef = useRef < number | null > ( null )
3839 const startPointerXRef = useRef ( 0 )
3940 const startOffsetXRef = useRef ( 0 )
41+ const sliderTravelRef = useRef ( 1 )
4042 const dragStartedAtRef = useRef ( 0 )
4143 const trailRef = useRef < number [ ] > ( [ ] )
4244 const latestOffsetRef = useRef ( 0 )
45+ const pendingOffsetRef = useRef ( 0 )
4346 const latestChallengeIdRef = useRef < string | null > ( null )
47+ const viewportScaleRef = useRef ( 1 )
48+ const frameRef = useRef < number | null > ( null )
49+ const viewportScale = viewportWidth / CAPTCHA_BASE_WIDTH
4450
45- useEffect ( ( ) => {
46- latestOffsetRef . current = offsetX
47- } , [ offsetX ] )
51+ const cancelScheduledOffsetSync = ( ) => {
52+ if ( frameRef . current !== null ) {
53+ window . cancelAnimationFrame ( frameRef . current )
54+ frameRef . current = null
55+ }
56+ }
57+
58+ const applyOffsetToDom = ( nextOffset : number ) => {
59+ const maxOffset = challenge ?. sliderMax ?? 0
60+ const safeOffset = Math . max ( 0 , Math . min ( nextOffset , maxOffset ) )
61+ const progress = maxOffset > 0 ? safeOffset / maxOffset : 0
62+
63+ latestOffsetRef . current = safeOffset
64+
65+ if ( handleRef . current ) {
66+ handleRef . current . style . transform = `translate3d(${ safeOffset } px, 0, 0)`
67+ }
68+
69+ if ( fillRef . current ) {
70+ fillRef . current . style . transform = `scaleX(${ progress } )`
71+ }
72+
73+ if ( pieceRef . current ) {
74+ pieceRef . current . style . transform = `translate3d(${ safeOffset * viewportScaleRef . current } px, 0, 0)`
75+ }
76+ }
77+
78+ const scheduleOffsetSync = ( nextOffset : number ) => {
79+ pendingOffsetRef . current = nextOffset
80+
81+ if ( frameRef . current !== null ) {
82+ return
83+ }
84+
85+ frameRef . current = window . requestAnimationFrame ( ( ) => {
86+ frameRef . current = null
87+ applyOffsetToDom ( pendingOffsetRef . current )
88+ } )
89+ }
4890
4991 useEffect ( ( ) => {
5092 if ( ! viewportRef . current ) {
@@ -53,7 +95,8 @@ export default function LoginCaptchaModal({
5395
5496 const updateWidth = ( ) => {
5597 const nextWidth = viewportRef . current ?. getBoundingClientRect ( ) . width ?? CAPTCHA_BASE_WIDTH
56- setViewportWidth ( nextWidth || CAPTCHA_BASE_WIDTH )
98+ const safeWidth = nextWidth || CAPTCHA_BASE_WIDTH
99+ setViewportWidth ( ( currentWidth ) => ( currentWidth === safeWidth ? currentWidth : safeWidth ) )
57100 }
58101
59102 updateWidth ( )
@@ -68,9 +111,15 @@ export default function LoginCaptchaModal({
68111 }
69112 } , [ open ] )
70113
114+ useEffect ( ( ) => {
115+ viewportScaleRef . current = viewportScale
116+ applyOffsetToDom ( latestOffsetRef . current )
117+ } , [ viewportScale , challenge ?. captchaId ] )
118+
71119 useEffect ( ( ) => {
72120 if ( ! open ) {
73- setOffsetX ( 0 )
121+ cancelScheduledOffsetSync ( )
122+ applyOffsetToDom ( 0 )
74123 setStatus ( 'idle' )
75124 trailRef . current = [ ]
76125 pointerIdRef . current = null
@@ -80,31 +129,34 @@ export default function LoginCaptchaModal({
80129
81130 if ( challenge ?. captchaId && latestChallengeIdRef . current !== challenge . captchaId ) {
82131 latestChallengeIdRef . current = challenge . captchaId
83- setOffsetX ( 0 )
132+ cancelScheduledOffsetSync ( )
133+ applyOffsetToDom ( 0 )
84134 setStatus ( 'idle' )
85135 trailRef . current = [ ]
86136 pointerIdRef . current = null
87137 }
88138 } , [ open , challenge ?. captchaId ] )
89139
140+ useEffect ( ( ) => ( ) => {
141+ cancelScheduledOffsetSync ( )
142+ } , [ ] )
143+
90144 useEffect ( ( ) => {
91145 const handlePointerMove = ( event : PointerEvent ) => {
92- if ( ! challenge || pointerIdRef . current !== event . pointerId || ! sliderRef . current ) {
146+ if ( ! challenge || pointerIdRef . current !== event . pointerId ) {
93147 return
94148 }
95149
96- const { width } = sliderRef . current . getBoundingClientRect ( )
97150 const maxOffset = challenge . sliderMax
98- const travel = Math . max ( width - 44 , 1 )
99151 const deltaX = event . clientX - startPointerXRef . current
100152 const nextOffset = Math . max (
101153 0 ,
102- Math . min ( maxOffset , startOffsetXRef . current + ( deltaX / travel ) * maxOffset ) ,
154+ Math . min ( maxOffset , startOffsetXRef . current + ( deltaX / sliderTravelRef . current ) * maxOffset ) ,
103155 )
104156 const rounded = Math . round ( nextOffset )
105157
106158 latestOffsetRef . current = rounded
107- setOffsetX ( rounded )
159+ scheduleOffsetSync ( rounded )
108160
109161 const last = trailRef . current [ trailRef . current . length - 1 ]
110162 if ( last !== rounded ) {
@@ -118,6 +170,8 @@ export default function LoginCaptchaModal({
118170 }
119171
120172 pointerIdRef . current = null
173+ cancelScheduledOffsetSync ( )
174+ applyOffsetToDom ( latestOffsetRef . current )
121175
122176 if ( latestOffsetRef . current <= 0 ) {
123177 setStatus ( 'idle' )
@@ -154,16 +208,20 @@ export default function LoginCaptchaModal({
154208 pointerIdRef . current = event . pointerId
155209 startPointerXRef . current = event . clientX
156210 startOffsetXRef . current = latestOffsetRef . current
211+ sliderTravelRef . current = Math . max ( ( sliderRef . current ?. getBoundingClientRect ( ) . width ?? 44 ) - 44 , 1 )
157212 dragStartedAtRef . current = Date . now ( )
158213 trailRef . current = [ latestOffsetRef . current ]
159214 setStatus ( 'dragging' )
215+
216+ event . currentTarget . setPointerCapture ( event . pointerId )
160217 }
161218
162219 const refreshChallenge = ( ) => {
163220 if ( verifying ) {
164221 return
165222 }
166- setOffsetX ( 0 )
223+ cancelScheduledOffsetSync ( )
224+ applyOffsetToDom ( 0 )
167225 setStatus ( 'idle' )
168226 trailRef . current = [ ]
169227 latestOffsetRef . current = 0
@@ -172,13 +230,11 @@ export default function LoginCaptchaModal({
172230 }
173231
174232 const statusText = status === 'error'
175- ? '轨迹不自然或未对准缺口,已刷新挑战 '
233+ ? '轨迹不自然或未对准缺口,请重试或刷新挑战 '
176234 : status === 'success'
177235 ? '验证通过,正在登录'
178236 : '按住滑块拖动,让拼图回到缺口位置'
179237
180- const viewportScale = viewportWidth / CAPTCHA_BASE_WIDTH
181-
182238 return (
183239 < Modal
184240 open = { open }
@@ -233,12 +289,13 @@ export default function LoginCaptchaModal({
233289 src = { challenge . piece }
234290 alt = "验证码拼图"
235291 className = "login-captcha__piece"
292+ ref = { pieceRef }
236293 draggable = { false }
237294 style = { {
238295 width : challenge . pieceSize * viewportScale ,
239296 height : challenge . pieceSize * viewportScale ,
240297 top : challenge . pieceTop * viewportScale ,
241- left : offsetX * viewportScale ,
298+ left : 0 ,
242299 } }
243300 />
244301 < div className = "login-captcha__scan" />
@@ -253,15 +310,15 @@ export default function LoginCaptchaModal({
253310 < div className = "login-captcha__slider" ref = { sliderRef } >
254311 < div
255312 className = "login-captcha__slider-fill"
256- style = { { width : challenge ? ` ${ ( offsetX / challenge . sliderMax ) * 100 } %` : 0 } }
313+ ref = { fillRef }
257314 />
258315 < div className = "login-captcha__slider-label" > 拖动完成验证</ div >
259316 < button
260317 type = "button"
261318 className = "login-captcha__handle"
319+ ref = { handleRef }
262320 onPointerDown = { handlePointerDown }
263321 disabled = { ! challenge || loading || verifying }
264- style = { { transform : `translateX(${ offsetX } px)` } }
265322 aria-label = "拖动滑块完成验证"
266323 >
267324 < span />
0 commit comments