轻松聘
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.9 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, 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<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: spacing.block,
},
});