Skip to content

Commit 12c8e9e

Browse files
committed
feat: 优化登录验证码滑块交互效果,提升用户体验
1 parent 526f028 commit 12c8e9e

3 files changed

Lines changed: 112 additions & 49 deletions

File tree

packages/mobile/src/main.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,59 @@
1-
import { StrictMode } from 'react'
2-
import { createRoot } from 'react-dom/client'
3-
import { useRegisterSW } from 'virtual:pwa-register/react'
4-
import './index.css'
5-
import App from './App'
1+
import { StrictMode } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import { useRegisterSW } from 'virtual:pwa-register/react';
4+
import './index.css';
5+
import App from './App';
66

77
if (import.meta.env.DEV && 'serviceWorker' in navigator) {
88
// 清掉此前开发态注册的 SW,避免缓存旧资源导致空白页。
99
void navigator.serviceWorker.getRegistrations().then((registrations) => {
1010
registrations.forEach((registration) => {
11-
void registration.unregister()
12-
})
13-
})
11+
void registration.unregister();
12+
});
13+
});
1414

1515
if ('caches' in window) {
1616
void caches.keys().then((cacheKeys) => {
1717
cacheKeys.forEach((cacheKey) => {
18-
void caches.delete(cacheKey)
19-
})
20-
})
18+
void caches.delete(cacheKey);
19+
});
20+
});
2121
}
2222
}
2323

2424
/** SW 更新通知组件 */
2525
function SWUpdateNotice() {
26-
const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW({
26+
const {
27+
needRefresh: [needRefresh],
28+
updateServiceWorker
29+
} = useRegisterSW({
2730
onRegistered(r) {
2831
// 每 60 分钟检查一次更新
29-
r && setInterval(() => r.update(), 60 * 60 * 1000)
30-
},
31-
})
32+
r && setInterval(() => r.update(), 60 * 60 * 1000);
33+
}
34+
});
3235

33-
if (!needRefresh) return null
36+
if (!needRefresh) return null;
3437

3538
return (
36-
<div className="fixed bottom-20 left-3 right-3 z-[100] rounded-xl border border-border bg-card p-3 shadow-2xl flex items-center justify-between gap-3">
37-
<p className="text-sm text-foreground">🔄 发现新版本</p>
39+
<div className='fixed bottom-20 left-3 right-3 z-[100] rounded-xl border border-border bg-card p-3 shadow-2xl flex items-center justify-between gap-3'>
40+
<p className='text-sm text-foreground'>🔄 发现新版本</p>
3841
<button
3942
onClick={() => updateServiceWorker(true)}
40-
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground"
43+
className='rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground'
4144
>
4245
立即更新
4346
</button>
4447
</div>
45-
)
48+
);
4649
}
4750

48-
const container = document.getElementById('root')!
49-
const root = createRoot(container)
51+
const container = document.getElementById('root')!;
52+
const root = createRoot(container);
5053

5154
root.render(
5255
<StrictMode>
5356
<App />
5457
<SWUpdateNotice />
55-
</StrictMode>,
56-
)
58+
</StrictMode>
59+
);

packages/web/src/pages/login/components/LoginCaptchaModal.tsx

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { LoginCaptchaChallenge, LoginCaptchaVerifyPayload } from '@/types'
55

66
const { Text } = Typography
77
const CAPTCHA_BASE_WIDTH = 320
8-
const CAPTCHA_BASE_HEIGHT = 180
98

109
type 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 />

packages/web/src/pages/login/index.less

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@
553553
z-index: 3;
554554
border-radius: 16px;
555555
box-shadow: 0 14px 26px rgba(2, 8, 5, 0.28);
556-
transition: left 0.08s ease-out;
556+
will-change: transform;
557557
}
558558

559559
&__scan {
@@ -616,10 +616,11 @@
616616

617617
&__slider-fill {
618618
position: absolute;
619-
inset: 0 auto 0 0;
620-
max-width: 100%;
619+
inset: 0;
621620
background: linear-gradient(90deg, rgba(29, 94, 66, 0.22), rgba(82, 183, 136, 0.35));
622-
transition: width 0.08s linear;
621+
transform: scaleX(0);
622+
transform-origin: left center;
623+
will-change: transform;
623624
}
624625

625626
&__slider-label {
@@ -648,7 +649,9 @@
648649
background: linear-gradient(180deg, #58c08c, #2d6a4f);
649650
box-shadow: 0 12px 24px rgba(45, 106, 79, 0.38);
650651
cursor: grab;
651-
transition: transform 0.08s linear, box-shadow 0.2s ease, opacity 0.2s ease;
652+
touch-action: none;
653+
will-change: transform;
654+
transition: box-shadow 0.2s ease, opacity 0.2s ease;
652655

653656
&:disabled {
654657
cursor: not-allowed;

0 commit comments

Comments
 (0)