sign-in.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import type { CaptchaRes } from '@/components/captcha-box';
  2. import CaptchaBox from '@/components/captcha-box';
  3. import { site } from '@/config.json';
  4. import { useInterval } from '@/hooks/hooks';
  5. import api, { ApiError } from '@/utils/api';
  6. import { signIn, smsSignIn, useAuth } from '@/utils/auth';
  7. import { Button, Toast } from '@ant-design/react-native';
  8. import { Ionicons } from '@expo/vector-icons';
  9. import { Link, router, useLocalSearchParams } from 'expo-router';
  10. import { openBrowserAsync } from 'expo-web-browser';
  11. import React, { useEffect, useRef, useState } from 'react';
  12. import {
  13. KeyboardAvoidingView,
  14. Platform,
  15. Pressable,
  16. ScrollView,
  17. StyleSheet,
  18. Text,
  19. TextInput,
  20. View
  21. } from 'react-native';
  22. import Animated, {
  23. interpolateColor,
  24. useAnimatedStyle,
  25. useSharedValue,
  26. withSequence,
  27. withTiming,
  28. } from 'react-native-reanimated';
  29. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  30. function FieldLabel({ children }: { children: React.ReactNode }) {
  31. return (
  32. <Text className="mb-2 ml-1 text-xs font-bold uppercase tracking-widest text-outline">
  33. {children}
  34. </Text>
  35. );
  36. }
  37. export default function SignInScreen() {
  38. const [authMode, setAuthMode] = useState<'sms' | 'password'>('sms');
  39. const [agreed, setAgreed] = useState(false);
  40. const [agreementHighlight, setFlushAgree] = useState(false);
  41. const [loading, setLoading] = useState(false);
  42. const [mobile, setMobile] = useState('');
  43. const [code, setCode] = useState('');
  44. const [password, setPassword] = useState('');
  45. const { redirectTo } = useLocalSearchParams<{ redirectTo?: string }>();
  46. const { setToken } = useAuth();
  47. const scrollViewRef = useRef<ScrollView>(null);
  48. const agreeHighlight = useSharedValue(0);
  49. useEffect(() => {
  50. if (agreementHighlight) {
  51. agreeHighlight.value = withSequence(
  52. withTiming(1, { duration: 140 }),
  53. withTiming(0, { duration: 140 }),
  54. withTiming(1, { duration: 140 }),
  55. withTiming(0, { duration: 140 }),
  56. withTiming(1, { duration: 200 }),
  57. );
  58. } else {
  59. agreeHighlight.value = withTiming(0, { duration: 200 });
  60. }
  61. }, [agreementHighlight, agreeHighlight]);
  62. const agreeBoxStyle = useAnimatedStyle(() => ({
  63. borderColor: interpolateColor(
  64. agreeHighlight.value,
  65. [0, 1],
  66. ['rgba(37, 99, 235, 0)', 'rgba(37, 99, 235, 0.5)'],
  67. ),
  68. }));
  69. const [captchaVisible, setCaptchaVisible] = useState<boolean>(false);
  70. const [smsTtl, setSmsTtl] = useState(0);
  71. const waitCaptcha = useRef<(res: CaptchaRes) => void | null>(null);
  72. const needsCaptchaRef = useRef(false);
  73. const handleCaptcha = (res: CaptchaRes) => {
  74. const resolver = waitCaptcha.current;
  75. waitCaptcha.current = null;
  76. setCaptchaVisible(false);
  77. resolver?.(res);
  78. };
  79. const handleSendCode = async () => {
  80. if (mobile.trim().length !== 11) {
  81. Toast.fail('请先输入 11 位手机号');
  82. return;
  83. }
  84. let captcha: CaptchaRes = null!;
  85. while (true) {
  86. if (needsCaptchaRef.current) {
  87. setCaptchaVisible(true);
  88. captcha = await new Promise<CaptchaRes>((resolve) => {
  89. waitCaptcha.current = resolve;
  90. });
  91. if (!captcha || !captcha.ok) {
  92. return;
  93. }
  94. }
  95. try {
  96. const res = await api.post<{
  97. needsCaptcha?: boolean;
  98. timerout: number;
  99. }>(`sms/send?__session_id=${captcha?.sid ?? ''}`, {
  100. mobile: mobile,
  101. event: 'login',
  102. captcha_code: captcha?.code,
  103. });
  104. if (res.needsCaptcha) {
  105. needsCaptchaRef.current = true;
  106. continue;
  107. }
  108. setSmsTtl(res.timerout); // 后端字段拼写为 timerout,保留以兼容接口
  109. break;
  110. } catch (e) {
  111. if (ApiError.isApiError(e)) {
  112. if (e.code === -1) {
  113. Toast.fail("该手机已被注册");
  114. return;
  115. }
  116. if (e.code === -99) {
  117. Toast.fail("图形验证码无效");
  118. return;
  119. }
  120. }
  121. Toast.fail("发送验证码失败");
  122. return;
  123. }
  124. }
  125. };
  126. useInterval(() => {
  127. setSmsTtl((prev) => prev - 1)
  128. }, smsTtl > 0 ? 1000 : null);
  129. const handleLogin = async () => {
  130. setFlushAgree(false);
  131. scrollViewRef.current?.scrollTo({ y: 0, animated: true });
  132. if (mobile.trim().length !== 11) {
  133. Toast.fail('请输入正确的手机号');
  134. return;
  135. }
  136. if (authMode === 'sms' && code.trim().length !== 6) {
  137. Toast.fail('请输入 6 位验证码');
  138. return;
  139. }
  140. if (authMode === 'password' && password.length < 6) {
  141. Toast.fail('请输入不少于 6 位的密码');
  142. return;
  143. }
  144. if (!agreed) {
  145. scrollViewRef.current?.scrollToEnd({ animated: true });
  146. setFlushAgree(true);
  147. Toast.fail('请先阅读并同意协议');
  148. return;
  149. }
  150. setLoading(true);
  151. try {
  152. const token = authMode === 'sms' ? await smsSignIn(mobile, code) : await signIn(mobile, password);
  153. setToken(token);
  154. Toast.success('登录成功');
  155. if (redirectTo) {
  156. router.replace(redirectTo as never);
  157. } else {
  158. router.canGoBack() ? router.back() : router.replace('/');
  159. }
  160. } catch (error) {
  161. console.error('登录失败:', error);
  162. Toast.fail('登录失败,请稍后重试');
  163. } finally {
  164. setLoading(false);
  165. }
  166. };
  167. const insets = useSafeAreaInsets();
  168. return (
  169. <View className="flex-1 bg-surface">
  170. <KeyboardAvoidingView
  171. className="flex-1"
  172. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  173. >
  174. <ScrollView
  175. ref={scrollViewRef}
  176. className="flex-1"
  177. contentContainerClassName="px-8"
  178. keyboardShouldPersistTaps="handled"
  179. showsVerticalScrollIndicator={false}
  180. >
  181. <View className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-primary-container/10" />
  182. <View className="absolute top-32 -left-20 h-64 w-64 rounded-full bg-secondary-container/20" />
  183. <View className="absolute -bottom-16 right-0 h-48 w-48 rounded-full bg-primary-fixed/50" />
  184. <View className="flex-1 justify-between"
  185. style={{ paddingTop: (insets.top ?? 10) + 52, paddingBottom: (insets.bottom ?? 8) + 24 }}>
  186. <View>
  187. <View className="mb-6">
  188. <View className="mb-3 h-16 w-16 items-center justify-center rounded-2xl bg-primary-container shadow-lg">
  189. <Ionicons name="business" size={30} color="#ffffff" />
  190. </View>
  191. <Text className="mb-2 text-4xl font-extrabold tracking-tight text-on-surface">
  192. 欢迎回来
  193. </Text>
  194. <Text className="text-base font-medium leading-6 text-on-surface-variant">
  195. 登录贷款助手,开启您的
  196. </Text>
  197. </View>
  198. <View className="mb-8 rounded-2xl bg-surface-container-lowest p-3 shadow-sm">
  199. <View className="mb-6 flex-row rounded-2xl bg-surface-container-low p-1">
  200. {[
  201. ['sms', '验证码登录'],
  202. ['password', '密码登录'],
  203. ].map(([mode, label]) => {
  204. const active = authMode === mode;
  205. return (
  206. <Pressable
  207. key={mode}
  208. disabled={loading}
  209. onPress={() => setAuthMode(mode as 'sms' | 'password')}
  210. className={`flex-1 rounded-xl px-4 py-3 ${active ? 'bg-surface-container-lowest' : ''
  211. }`}
  212. style={({ pressed }) => ({
  213. opacity: loading ? 0.5 : pressed ? 0.86 : 1,
  214. transform: [{ scale: pressed ? 0.99 : 1 }],
  215. })}
  216. >
  217. <Text
  218. className={`text-center text-base font-bold ${active ? 'text-primary' : 'text-on-surface-variant'
  219. }`}
  220. >
  221. {label}
  222. </Text>
  223. </Pressable>
  224. );
  225. })}
  226. </View>
  227. <View className="px-2 pb-2">
  228. <FieldLabel>手机号码</FieldLabel>
  229. <View className="mb-5 flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
  230. <Text className="text-2xl font-bold text-on-surface">+86</Text>
  231. <View
  232. className="mx-4 h-5 bg-outline-variant/40"
  233. style={{ width: StyleSheet.hairlineWidth }}
  234. />
  235. <TextInput
  236. value={mobile}
  237. onChangeText={setMobile}
  238. editable={!loading}
  239. keyboardType="phone-pad"
  240. maxLength={11}
  241. placeholder="请输入手机号"
  242. placeholderTextColor="#9ca3af"
  243. className="flex-1 p-0 text-xl font-medium text-on-surface"
  244. />
  245. <Ionicons
  246. name="phone-portrait-outline"
  247. size={22}
  248. color="#c3c6d7"
  249. />
  250. </View>
  251. <FieldLabel>{authMode === 'sms' ? '验证码' : '密码'}</FieldLabel>
  252. <View className="flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
  253. <TextInput
  254. value={authMode === 'sms' ? code : password}
  255. onChangeText={authMode === 'sms' ? setCode : setPassword}
  256. editable={!loading}
  257. keyboardType={authMode === 'sms' ? 'number-pad' : 'default'}
  258. maxLength={authMode === 'sms' ? 6 : 20}
  259. secureTextEntry={authMode === 'password'}
  260. placeholder={
  261. authMode === 'sms'
  262. ? '请输入 6 位验证码'
  263. : '请输入 6-20 位登录密码'
  264. }
  265. placeholderTextColor="#9ca3af"
  266. className="flex-1 p-0 text-xl font-medium text-on-surface"
  267. />
  268. {authMode === 'sms' ? (
  269. <Pressable
  270. disabled={loading || smsTtl > 0}
  271. hitSlop={8}
  272. onPress={handleSendCode}
  273. >
  274. <Text className={`text-lg w-22 text-center font-bold leading-6 ${loading || smsTtl > 0 ? 'text-on-secondary-fixed/50' : 'text-primary'}`}>
  275. {smsTtl > 0 ? `${smsTtl}s` : '发送验证码'}
  276. </Text>
  277. </Pressable>
  278. ) : (
  279. <Ionicons name="lock-closed-outline" size={22} color="#c3c6d7" />
  280. )}
  281. </View>
  282. </View>
  283. </View>
  284. <View className="mb-6 gap-3">
  285. <Button type='primary' loading={loading} onPress={handleLogin}>登录</Button>
  286. <View className="flex-row items-center justify-between px-3">
  287. <Pressable hitSlop={8} onPress={() => router.push({ pathname: '/sign-up', params: { signIn: 1, redirectTo } })}>
  288. <Text className="text-lg font-medium text-on-surface-variant">
  289. 注册账号
  290. </Text>
  291. </Pressable>
  292. <Pressable hitSlop={8}>
  293. <Text className="text-lg font-medium text-on-surface-variant">
  294. 遇到问题?
  295. </Text>
  296. </Pressable>
  297. </View>
  298. </View>
  299. </View>
  300. <View className="pt-1">
  301. <View className="mb-5 flex-row items-center gap-3">
  302. <View className="h-px flex-1 bg-surface-container-highest" />
  303. <Text className="text-sm font-bold tracking-widest text-outline">
  304. 其他方式登录
  305. </Text>
  306. <View className="h-px flex-1 bg-surface-container-highest" />
  307. </View>
  308. <View className="mb-6 flex-row justify-center gap-8">
  309. {[
  310. ['chatbubble-ellipses-outline', '#6b7280'],
  311. ['logo-apple', '#6b7280'],
  312. ].map(([icon, color]) => (
  313. <Pressable
  314. key={icon}
  315. className="h-14 w-14 items-center justify-center rounded-full bg-surface-container-low"
  316. style={({ pressed }) => ({
  317. opacity: pressed ? 0.8 : 1,
  318. transform: [{ scale: pressed ? 0.94 : 1 }],
  319. })}
  320. >
  321. <Ionicons
  322. name={icon as keyof typeof Ionicons.glyphMap}
  323. size={24}
  324. color={color}
  325. />
  326. </Pressable>
  327. ))}
  328. </View>
  329. <Animated.View
  330. style={agreeBoxStyle}
  331. className="flex-row items-start gap-3 px-2 rounded-lg border-2"
  332. >
  333. <Pressable
  334. onPress={() => setAgreed((value) => !value)}
  335. hitSlop={8}
  336. className="pt-1"
  337. >
  338. <View
  339. className={`h-6 w-6 mt-2 items-center justify-center border-2 border-primary/50 rounded-full cursor-pointer ${agreed
  340. ? 'border-primary bg-primary'
  341. : 'border-outline-variant bg-surface-container-low'
  342. }`}
  343. >
  344. {agreed ? (
  345. <Ionicons name="checkmark" size={14} color="#ffffff" />
  346. ) : null}
  347. </View>
  348. </Pressable>
  349. <Text className="flex-1 text-sm leading-6 text-on-surface-variant">
  350. 登录即代表您已阅读并同意
  351. <Link href={{
  352. pathname: '/web',
  353. params: { uri: site }
  354. }}>
  355. <Text className="font-semibold text-primary">《用户服务协议》</Text>
  356. </Link>
  357. <Text className="font-semibold text-primary cursor-pointer" onPress={() => openBrowserAsync(`${site}`)}>《隐私政策》</Text>
  358. ,以及授权该应用获取您的公开信息。
  359. </Text>
  360. </Animated.View>
  361. </View>
  362. </View>
  363. </ScrollView>
  364. </KeyboardAvoidingView>
  365. <CaptchaBox visible={captchaVisible} onClose={handleCaptcha} />
  366. {loading && <View className="absolute left-0 right-0 top-0 bottom-0 z-1 bg-white/5" />}
  367. </View>
  368. );
  369. }