| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- import type { CaptchaRes } from '@/components/captcha-box';
- import CaptchaBox from '@/components/captcha-box';
- import { site } from '@/config.json';
- import { useInterval } from '@/hooks/hooks';
- import api, { ApiError } from '@/utils/api';
- import { signIn, smsSignIn, useAuth } from '@/utils/auth';
- import { Button, Toast } from '@ant-design/react-native';
- import { Ionicons } from '@expo/vector-icons';
- import { Link, router, useLocalSearchParams } from 'expo-router';
- import { openBrowserAsync } from 'expo-web-browser';
- import React, { useEffect, useRef, useState } from 'react';
- import {
- KeyboardAvoidingView,
- Platform,
- Pressable,
- ScrollView,
- StyleSheet,
- Text,
- TextInput,
- View
- } from 'react-native';
- import Animated, {
- interpolateColor,
- useAnimatedStyle,
- useSharedValue,
- withSequence,
- withTiming,
- } from 'react-native-reanimated';
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
- function FieldLabel({ children }: { children: React.ReactNode }) {
- return (
- <Text className="mb-2 ml-1 text-xs font-bold uppercase tracking-widest text-outline">
- {children}
- </Text>
- );
- }
- export default function SignInScreen() {
- const [authMode, setAuthMode] = useState<'sms' | 'password'>('sms');
- const [agreed, setAgreed] = useState(false);
- const [agreementHighlight, setFlushAgree] = useState(false);
- const [loading, setLoading] = useState(false);
- const [mobile, setMobile] = useState('');
- const [code, setCode] = useState('');
- const [password, setPassword] = useState('');
- const { redirectTo } = useLocalSearchParams<{ redirectTo?: string }>();
- const { setToken } = useAuth();
- const scrollViewRef = useRef<ScrollView>(null);
- const agreeHighlight = useSharedValue(0);
- useEffect(() => {
- if (agreementHighlight) {
- agreeHighlight.value = withSequence(
- withTiming(1, { duration: 140 }),
- withTiming(0, { duration: 140 }),
- withTiming(1, { duration: 140 }),
- withTiming(0, { duration: 140 }),
- withTiming(1, { duration: 200 }),
- );
- } else {
- agreeHighlight.value = withTiming(0, { duration: 200 });
- }
- }, [agreementHighlight, agreeHighlight]);
- const agreeBoxStyle = useAnimatedStyle(() => ({
- borderColor: interpolateColor(
- agreeHighlight.value,
- [0, 1],
- ['rgba(37, 99, 235, 0)', 'rgba(37, 99, 235, 0.5)'],
- ),
- }));
- const [captchaVisible, setCaptchaVisible] = useState<boolean>(false);
- const [smsTtl, setSmsTtl] = useState(0);
- const waitCaptcha = useRef<(res: CaptchaRes) => void | null>(null);
- const needsCaptchaRef = useRef(false);
- const handleCaptcha = (res: CaptchaRes) => {
- const resolver = waitCaptcha.current;
- waitCaptcha.current = null;
- setCaptchaVisible(false);
- resolver?.(res);
- };
- const handleSendCode = async () => {
- if (mobile.trim().length !== 11) {
- Toast.fail('请先输入 11 位手机号');
- return;
- }
- let captcha: CaptchaRes = null!;
- while (true) {
- if (needsCaptchaRef.current) {
- setCaptchaVisible(true);
- captcha = await new Promise<CaptchaRes>((resolve) => {
- waitCaptcha.current = resolve;
- });
- if (!captcha || !captcha.ok) {
- return;
- }
- }
- try {
- const res = await api.post<{
- needsCaptcha?: boolean;
- timerout: number;
- }>(`sms/send?__session_id=${captcha?.sid ?? ''}`, {
- mobile: mobile,
- event: 'login',
- captcha_code: captcha?.code,
- });
- if (res.needsCaptcha) {
- needsCaptchaRef.current = true;
- continue;
- }
- setSmsTtl(res.timerout); // 后端字段拼写为 timerout,保留以兼容接口
- break;
- } catch (e) {
- if (ApiError.isApiError(e)) {
- if (e.code === -1) {
- Toast.fail("该手机已被注册");
- return;
- }
- if (e.code === -99) {
- Toast.fail("图形验证码无效");
- return;
- }
- }
- Toast.fail("发送验证码失败");
- return;
- }
- }
- };
- useInterval(() => {
- setSmsTtl((prev) => prev - 1)
- }, smsTtl > 0 ? 1000 : null);
- const handleLogin = async () => {
- setFlushAgree(false);
- scrollViewRef.current?.scrollTo({ y: 0, animated: true });
- if (mobile.trim().length !== 11) {
- Toast.fail('请输入正确的手机号');
- return;
- }
- if (authMode === 'sms' && code.trim().length !== 6) {
- Toast.fail('请输入 6 位验证码');
- return;
- }
- if (authMode === 'password' && password.length < 6) {
- Toast.fail('请输入不少于 6 位的密码');
- return;
- }
- if (!agreed) {
- scrollViewRef.current?.scrollToEnd({ animated: true });
- setFlushAgree(true);
- Toast.fail('请先阅读并同意协议');
- return;
- }
- setLoading(true);
- try {
- const token = authMode === 'sms' ? await smsSignIn(mobile, code) : await signIn(mobile, password);
- setToken(token);
- Toast.success('登录成功');
- if (redirectTo) {
- router.replace(redirectTo as never);
- } else {
- router.canGoBack() ? router.back() : router.replace('/');
- }
- } catch (error) {
- console.error('登录失败:', error);
- Toast.fail('登录失败,请稍后重试');
- } finally {
- setLoading(false);
- }
- };
- const insets = useSafeAreaInsets();
- return (
- <View className="flex-1 bg-surface">
- <KeyboardAvoidingView
- className="flex-1"
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- >
- <ScrollView
- ref={scrollViewRef}
- className="flex-1"
- contentContainerClassName="px-8"
- keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
- >
- <View className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-primary-container/10" />
- <View className="absolute top-32 -left-20 h-64 w-64 rounded-full bg-secondary-container/20" />
- <View className="absolute -bottom-16 right-0 h-48 w-48 rounded-full bg-primary-fixed/50" />
- <View className="flex-1 justify-between"
- style={{ paddingTop: (insets.top ?? 10) + 52, paddingBottom: (insets.bottom ?? 8) + 24 }}>
- <View>
- <View className="mb-6">
- <View className="mb-3 h-16 w-16 items-center justify-center rounded-2xl bg-primary-container shadow-lg">
- <Ionicons name="business" size={30} color="#ffffff" />
- </View>
- <Text className="mb-2 text-4xl font-extrabold tracking-tight text-on-surface">
- 欢迎回来
- </Text>
- <Text className="text-base font-medium leading-6 text-on-surface-variant">
- 登录贷款助手,开启您的
- </Text>
- </View>
- <View className="mb-8 rounded-2xl bg-surface-container-lowest p-3 shadow-sm">
- <View className="mb-6 flex-row rounded-2xl bg-surface-container-low p-1">
- {[
- ['sms', '验证码登录'],
- ['password', '密码登录'],
- ].map(([mode, label]) => {
- const active = authMode === mode;
- return (
- <Pressable
- key={mode}
- disabled={loading}
- onPress={() => setAuthMode(mode as 'sms' | 'password')}
- className={`flex-1 rounded-xl px-4 py-3 ${active ? 'bg-surface-container-lowest' : ''
- }`}
- style={({ pressed }) => ({
- opacity: loading ? 0.5 : pressed ? 0.86 : 1,
- transform: [{ scale: pressed ? 0.99 : 1 }],
- })}
- >
- <Text
- className={`text-center text-base font-bold ${active ? 'text-primary' : 'text-on-surface-variant'
- }`}
- >
- {label}
- </Text>
- </Pressable>
- );
- })}
- </View>
- <View className="px-2 pb-2">
- <FieldLabel>手机号码</FieldLabel>
- <View className="mb-5 flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
- <Text className="text-2xl font-bold text-on-surface">+86</Text>
- <View
- className="mx-4 h-5 bg-outline-variant/40"
- style={{ width: StyleSheet.hairlineWidth }}
- />
- <TextInput
- value={mobile}
- onChangeText={setMobile}
- editable={!loading}
- keyboardType="phone-pad"
- maxLength={11}
- placeholder="请输入手机号"
- placeholderTextColor="#9ca3af"
- className="flex-1 p-0 text-xl font-medium text-on-surface"
- />
- <Ionicons
- name="phone-portrait-outline"
- size={22}
- color="#c3c6d7"
- />
- </View>
- <FieldLabel>{authMode === 'sms' ? '验证码' : '密码'}</FieldLabel>
- <View className="flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
- <TextInput
- value={authMode === 'sms' ? code : password}
- onChangeText={authMode === 'sms' ? setCode : setPassword}
- editable={!loading}
- keyboardType={authMode === 'sms' ? 'number-pad' : 'default'}
- maxLength={authMode === 'sms' ? 6 : 20}
- secureTextEntry={authMode === 'password'}
- placeholder={
- authMode === 'sms'
- ? '请输入 6 位验证码'
- : '请输入 6-20 位登录密码'
- }
- placeholderTextColor="#9ca3af"
- className="flex-1 p-0 text-xl font-medium text-on-surface"
- />
- {authMode === 'sms' ? (
- <Pressable
- disabled={loading || smsTtl > 0}
- hitSlop={8}
- onPress={handleSendCode}
- >
- <Text className={`text-lg w-22 text-center font-bold leading-6 ${loading || smsTtl > 0 ? 'text-on-secondary-fixed/50' : 'text-primary'}`}>
- {smsTtl > 0 ? `${smsTtl}s` : '发送验证码'}
- </Text>
- </Pressable>
- ) : (
- <Ionicons name="lock-closed-outline" size={22} color="#c3c6d7" />
- )}
- </View>
- </View>
- </View>
- <View className="mb-6 gap-3">
- <Button type='primary' loading={loading} onPress={handleLogin}>登录</Button>
- <View className="flex-row items-center justify-between px-3">
- <Pressable hitSlop={8} onPress={() => router.push({ pathname: '/sign-up', params: { signIn: 1, redirectTo } })}>
- <Text className="text-lg font-medium text-on-surface-variant">
- 注册账号
- </Text>
- </Pressable>
- <Pressable hitSlop={8}>
- <Text className="text-lg font-medium text-on-surface-variant">
- 遇到问题?
- </Text>
- </Pressable>
- </View>
- </View>
- </View>
- <View className="pt-1">
- <View className="mb-5 flex-row items-center gap-3">
- <View className="h-px flex-1 bg-surface-container-highest" />
- <Text className="text-sm font-bold tracking-widest text-outline">
- 其他方式登录
- </Text>
- <View className="h-px flex-1 bg-surface-container-highest" />
- </View>
- <View className="mb-6 flex-row justify-center gap-8">
- {[
- ['chatbubble-ellipses-outline', '#6b7280'],
- ['logo-apple', '#6b7280'],
- ].map(([icon, color]) => (
- <Pressable
- key={icon}
- className="h-14 w-14 items-center justify-center rounded-full bg-surface-container-low"
- style={({ pressed }) => ({
- opacity: pressed ? 0.8 : 1,
- transform: [{ scale: pressed ? 0.94 : 1 }],
- })}
- >
- <Ionicons
- name={icon as keyof typeof Ionicons.glyphMap}
- size={24}
- color={color}
- />
- </Pressable>
- ))}
- </View>
- <Animated.View
- style={agreeBoxStyle}
- className="flex-row items-start gap-3 px-2 rounded-lg border-2"
- >
- <Pressable
- onPress={() => setAgreed((value) => !value)}
- hitSlop={8}
- className="pt-1"
- >
- <View
- className={`h-6 w-6 mt-2 items-center justify-center border-2 border-primary/50 rounded-full cursor-pointer ${agreed
- ? 'border-primary bg-primary'
- : 'border-outline-variant bg-surface-container-low'
- }`}
- >
- {agreed ? (
- <Ionicons name="checkmark" size={14} color="#ffffff" />
- ) : null}
- </View>
- </Pressable>
- <Text className="flex-1 text-sm leading-6 text-on-surface-variant">
- 登录即代表您已阅读并同意
- <Link href={{
- pathname: '/web',
- params: { uri: site }
- }}>
- <Text className="font-semibold text-primary">《用户服务协议》</Text>
- </Link>
- 、
- <Text className="font-semibold text-primary cursor-pointer" onPress={() => openBrowserAsync(`${site}`)}>《隐私政策》</Text>
- ,以及授权该应用获取您的公开信息。
- </Text>
- </Animated.View>
- </View>
- </View>
- </ScrollView>
- </KeyboardAvoidingView>
- <CaptchaBox visible={captchaVisible} onClose={handleCaptcha} />
- {loading && <View className="absolute left-0 right-0 top-0 bottom-0 z-1 bg-white/5" />}
- </View>
- );
- }
|