import { useCallback, useEffect, useRef, useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; import type { ImageSourcePropType } from 'react-native'; import { Circle, ClipPath, Defs, G, Image as SvgImage, LinearGradient, Path, Rect, Stop, Svg, } from 'react-native-svg'; import { colors, spacing } from '../../../src/theme'; import { Modal } from './Modal'; import { CAPTCHA_BACKGROUNDS } from '../assets'; interface Props { visible: boolean; /** 拼图块对齐缺口时判定通过 */ onSuccess: () => void; /** 点遮罩 / 关闭 */ onClose: () => void; } const KNOB = 44; const AREA_H = 170; /** 生成经典拼图形状(四边各一个半圆凸起)的 SVG path */ function jigsaw(x: number, y: number, w: number, h: number): string { const r = w * 0.24; // 凸起半宽 const d = w * 0.2; // 凸起深度 const mx = x + w / 2; const my = y + h / 2; return [ `M ${x} ${y}`, `L ${mx - r} ${y}`, `C ${mx - r} ${y - d}, ${mx + r} ${y - d}, ${mx + r} ${y}`, `L ${x + w} ${y}`, `L ${x + w} ${my - r}`, `C ${x + w + d} ${my - r}, ${x + w + d} ${my + r}, ${x + w} ${my + r}`, `L ${x + w} ${y + h}`, `L ${mx + r} ${y + h}`, `C ${mx + r} ${y + h + d}, ${mx - r} ${y + h + d}, ${mx - r} ${y + h}`, `L ${x} ${y + h}`, `L ${x} ${my + r}`, `C ${x - d} ${my + r}, ${x - d} ${my - r}, ${x} ${my - r}`, 'Z', ].join(' '); } interface Puzzle { PW: number; PH: number; TY: number; targetPieceX: number; targetDx: number; leftMargin: number; pieceTravelRange: number; } /** 滑块拼图验证弹窗:拖动滑块使拼图块对齐随机缺口,位置匹配即判定通过 */ export function SliderCaptcha({ visible, onSuccess, onClose }: Props) { const [areaW, setAreaW] = useState(0); const [trackW, setTrackW] = useState(0); const [dx, setDx] = useState(0); const [passed, setPassed] = useState(false); const [puzzle, setPuzzle] = useState(null); const [refreshKey, setRefreshKey] = useState(0); /** 当前使用的背景图(每次 generate 随机从池中选取) */ const [bg, setBg] = useState(null); const startX = useRef(0); const knobMaxDx = Math.max(0, trackW - KNOB - 4); // 用 ref 持有最新状态,避免 PanResponder 闭包捕获首帧旧值(puzzle 为 null)导致滑块无法抢占手势、划不动 const latest = useRef({ dx: 0, knobMaxDx: 0, passed: false, puzzle: null as Puzzle | null, onSuccess }); latest.current = { dx, knobMaxDx, passed, puzzle, onSuccess }; // 测量完成后随机生成缺口位置、拼图块目标位移,并随机选取背景图 const generate = useCallback(() => { if (areaW <= 0 || trackW <= 0) return; const maxDx = Math.max(10, trackW - KNOB - 4); const PW = Math.min(Math.max(areaW * 0.28, 54), 96); const PH = PW; const leftMargin = 6; const pieceTravelRange = Math.max(10, areaW - PW - leftMargin * 2); const targetPieceX = leftMargin + 24 + Math.random() * Math.max(1, pieceTravelRange - 48); const TY = 16 + Math.random() * Math.max(1, AREA_H - PH - 24); const targetDx = ((targetPieceX - leftMargin) / pieceTravelRange) * maxDx; setPuzzle({ PW, PH, TY, targetPieceX, targetDx, leftMargin, pieceTravelRange }); setDx(0); setPassed(false); // 从背景图池中随机选一张 if (CAPTCHA_BACKGROUNDS.length > 0) { setBg(CAPTCHA_BACKGROUNDS[Math.floor(Math.random() * CAPTCHA_BACKGROUNDS.length)]); } }, [areaW, trackW]); useEffect(() => { if (visible && areaW > 0 && trackW > 0) generate(); }, [visible, areaW, trackW, refreshKey, generate]); const pieceX = puzzle && knobMaxDx > 0 ? puzzle.leftMargin + (dx / knobMaxDx) * puzzle.pieceTravelRange : puzzle?.leftMargin ?? 0; const pan = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, onMoveShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, onPanResponderGrant: (e) => { startX.current = e.nativeEvent.pageX; }, onPanResponderMove: (e) => { const { puzzle: pz, knobMaxDx: max } = latest.current; if (!pz) return; const next = Math.min(Math.max(e.nativeEvent.pageX - startX.current, 0), max); latest.current.dx = next; // 同步到 ref,保证松手判定用的是最终位移 setDx(next); }, onPanResponderRelease: () => { const { puzzle: pz, knobMaxDx: max, dx: cur, onSuccess: ok } = latest.current; if (!pz) return; const tol = Math.max(8, max * 0.045); if (Math.abs(cur - pz.targetDx) <= tol) { setDx(pz.targetDx); setPassed(true); setTimeout(ok, 400); } else { setDx(0); } }, }), ).current; const reset = () => { setDx(0); setPassed(false); }; const refresh = () => { reset(); setRefreshKey((k) => k + 1); }; const close = () => { reset(); onClose(); }; // 背景:有图片则裁切填充,否则用渐变 + 几何纹理占位(与拼图块共用同一来源保证对齐) const renderBg = (clip?: string) => bg ? ( ) : ( ); return ( {/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */} setAreaW(e.nativeEvent.layout.width)}> {areaW > 0 && puzzle ? ( {/* 静态背景 */} {renderBg()} {/* 目标缺口(待填充位置) */} {/* 可拖动拼图块 */} {renderBg('url(#pieceClip)')} ) : ( 拼图加载中… )} {/* 滑块轨道 */} setTrackW(e.nativeEvent.layout.width)}> 8 || passed ? 0 : 1 }]}> {passed ? '验证通过' : '拖动滑块完成拼图'} {/* 底部操作行 */} ); } const styles = StyleSheet.create({ puzzleArea: { height: AREA_H, borderRadius: 12, backgroundColor: colors.primaryLight, overflow: 'hidden', }, svg: { position: 'absolute', left: 0, top: 0 }, puzzlePlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center', }, puzzleHint: { fontSize: 14, color: colors.textHint }, track: { height: 44, borderRadius: 22, backgroundColor: colors.borderLight, marginTop: 16, justifyContent: 'center', overflow: 'hidden', }, trackHint: { textAlign: 'center', fontSize: 14, color: colors.textHint }, knob: { position: 'absolute', left: 2, top: 2, width: KNOB, height: KNOB, borderRadius: 22, backgroundColor: colors.white, alignItems: 'center', justifyContent: 'center', shadowColor: colors.textPrimary, shadowOpacity: 0.15, shadowRadius: 4, elevation: 3, }, knobPassed: { backgroundColor: colors.success }, toolRow: { flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', marginTop: spacing.block, }, });