You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
306 lines
9.8 KiB
306 lines
9.8 KiB
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 } 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<Puzzle | null>(null);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
/** 当前使用的背景图(每次 generate 随机从池中选取) */
|
|
const [bg, setBg] = useState<ImageSourcePropType | null>(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 ? (
|
|
<SvgImage
|
|
href={bg as number}
|
|
x={0}
|
|
y={0}
|
|
width={areaW}
|
|
height={AREA_H}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
clipPath={clip}
|
|
/>
|
|
) : (
|
|
<G clipPath={clip}>
|
|
<Rect x={0} y={0} width={areaW} height={AREA_H} fill="url(#capBg)" />
|
|
<Circle cx={areaW * 0.25} cy={AREA_H * 0.3} r={34} fill={colors.white} opacity={0.12} />
|
|
<Circle cx={areaW * 0.7} cy={AREA_H * 0.72} r={46} fill={colors.white} opacity={0.1} />
|
|
<Circle cx={areaW * 0.86} cy={AREA_H * 0.2} r={20} fill={colors.white} opacity={0.14} />
|
|
</G>
|
|
);
|
|
|
|
return (
|
|
<Modal visible={visible} onClose={onClose}>
|
|
{/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */}
|
|
<View style={styles.puzzleArea} onLayout={(e) => setAreaW(e.nativeEvent.layout.width)}>
|
|
{areaW > 0 && puzzle ? (
|
|
<Svg width={areaW} height={AREA_H} style={styles.svg}>
|
|
<Defs>
|
|
<LinearGradient
|
|
id="capBg"
|
|
x1={0}
|
|
y1={0}
|
|
x2={areaW}
|
|
y2={AREA_H}
|
|
gradientUnits="userSpaceOnUse"
|
|
>
|
|
<Stop offset={0} stopColor={colors.primary} />
|
|
<Stop offset={1} stopColor={colors.success} />
|
|
</LinearGradient>
|
|
<ClipPath id="pieceClip">
|
|
<Path d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} />
|
|
</ClipPath>
|
|
</Defs>
|
|
|
|
{/* 静态背景 */}
|
|
{renderBg()}
|
|
|
|
{/* 目标缺口(待填充位置) */}
|
|
<Path
|
|
d={jigsaw(puzzle.targetPieceX, puzzle.TY, puzzle.PW, puzzle.PH)}
|
|
fill={colors.scrim}
|
|
opacity={0.55}
|
|
stroke={colors.white}
|
|
strokeWidth={2}
|
|
/>
|
|
|
|
{/* 可拖动拼图块 */}
|
|
<Path
|
|
d={jigsaw(pieceX + 2, puzzle.TY + 2, puzzle.PW, puzzle.PH)}
|
|
fill={colors.textPrimary}
|
|
opacity={0.18}
|
|
/>
|
|
{renderBg('url(#pieceClip)')}
|
|
<Path
|
|
d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)}
|
|
fill="none"
|
|
stroke={colors.primary}
|
|
strokeWidth={2.5}
|
|
/>
|
|
</Svg>
|
|
) : (
|
|
<View style={styles.puzzlePlaceholder}>
|
|
<Text style={styles.puzzleHint}>拼图加载中…</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 滑块轨道 */}
|
|
<View style={styles.track} onLayout={(e) => setTrackW(e.nativeEvent.layout.width)}>
|
|
<Text style={[styles.trackHint, { opacity: dx > 8 || passed ? 0 : 1 }]}>
|
|
{passed ? '验证通过' : '拖动滑块完成拼图'}
|
|
</Text>
|
|
<View
|
|
style={[styles.knob, { transform: [{ translateX: dx }] }, passed && styles.knobPassed]}
|
|
{...pan.panHandlers}
|
|
>
|
|
<Ionicons
|
|
name={passed ? 'checkmark' : 'reorder-three'}
|
|
size={22}
|
|
color={passed ? colors.textInverse : colors.primary}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 底部操作行 */}
|
|
<View style={styles.toolRow}>
|
|
<Pressable onPress={close} hitSlop={8}>
|
|
<Ionicons name="close-circle-outline" size={22} color={colors.textHint} />
|
|
</Pressable>
|
|
<Pressable onPress={refresh} hitSlop={8}>
|
|
<Ionicons name="refresh" size={22} color={colors.textHint} />
|
|
</Pressable>
|
|
<Ionicons name="volume-medium-outline" size={22} color={colors.textHint} />
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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: 14,
|
|
},
|
|
});
|
|
|