login.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { useRouter } from 'expo-router';
  2. import React, { useEffect, useRef, useState } from 'react';
  3. import {
  4. Alert,
  5. ImageBackground,
  6. KeyboardAvoidingView,
  7. Platform,
  8. ScrollView,
  9. StatusBar,
  10. StyleSheet,
  11. Text,
  12. TextInput,
  13. TouchableOpacity,
  14. View,
  15. } from 'react-native';
  16. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  17. import { Images } from '@/constants/images';
  18. import { login, sendVerifyCode } from '@/services/user';
  19. export default function LoginScreen() {
  20. const router = useRouter();
  21. const insets = useSafeAreaInsets();
  22. const [phone, setPhone] = useState('');
  23. const [verifyCode, setVerifyCode] = useState('');
  24. const [agreeFlag, setAgreeFlag] = useState(false);
  25. const [countdown, setCountdown] = useState(0);
  26. const [disabled, setDisabled] = useState(false);
  27. const [loading, setLoading] = useState(false);
  28. const [tips, setTips] = useState('获取验证码');
  29. const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  30. useEffect(() => {
  31. return () => {
  32. if (timerRef.current) {
  33. clearInterval(timerRef.current);
  34. }
  35. };
  36. }, []);
  37. // 验证手机号
  38. const isChinesePhoneNumber = (phoneNumber: string) => {
  39. const phoneNumberPattern = /^1[3-9]\d{9}$/;
  40. return phoneNumberPattern.test(phoneNumber);
  41. };
  42. // 开始倒计时
  43. const startCountdown = () => {
  44. setDisabled(true);
  45. let count = 60;
  46. setTips(`${count}s后重新获取`);
  47. timerRef.current = setInterval(() => {
  48. count--;
  49. if (count > 0) {
  50. setTips(`${count}s后重新获取`);
  51. } else {
  52. resetCountdown();
  53. }
  54. }, 1000);
  55. };
  56. // 重置倒计时
  57. const resetCountdown = () => {
  58. setDisabled(false);
  59. setTips('获取验证码');
  60. if (timerRef.current) {
  61. clearInterval(timerRef.current);
  62. timerRef.current = null;
  63. }
  64. };
  65. // 获取验证码
  66. const handleGetVerifyCode = async () => {
  67. if (disabled) return;
  68. if (phone && isChinesePhoneNumber(phone)) {
  69. try {
  70. const res = await sendVerifyCode(phone, 'LOGIN');
  71. if (res) {
  72. Alert.alert('提示', '验证码已发送');
  73. startCountdown();
  74. }
  75. } catch (error) {
  76. Alert.alert('错误', '获取验证码失败,请重试');
  77. }
  78. } else {
  79. Alert.alert('提示', '请输入正确的手机号');
  80. }
  81. };
  82. // 登录
  83. const handleLogin = async () => {
  84. if (!agreeFlag) {
  85. Alert.alert('提示', '请您先阅读并同意用户协议和隐私政策');
  86. return;
  87. }
  88. if (phone && isChinesePhoneNumber(phone) && verifyCode) {
  89. setLoading(true);
  90. try {
  91. const result = await login({
  92. loginWay: 'MOBILE',
  93. mobile: phone,
  94. verifycode: verifyCode,
  95. });
  96. if (result.success) {
  97. // TODO: 如果 needInfo 为 true,跳转到完善信息页面
  98. // if (result.needInfo) {
  99. // router.replace('/user-info');
  100. // return;
  101. // }
  102. router.back();
  103. } else {
  104. Alert.alert('错误', '登录失败,请检查验证码');
  105. }
  106. } catch (error) {
  107. Alert.alert('错误', '登录失败');
  108. }
  109. setLoading(false);
  110. } else {
  111. Alert.alert('提示', '请输入手机号和验证码');
  112. }
  113. };
  114. const goBack = () => {
  115. router.back();
  116. };
  117. return (
  118. <View style={styles.container}>
  119. <StatusBar barStyle="light-content" />
  120. <ImageBackground
  121. source={{ uri: Images.common.loginBg }}
  122. style={styles.background}
  123. resizeMode="cover"
  124. >
  125. <KeyboardAvoidingView
  126. style={styles.keyboardView}
  127. behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  128. >
  129. <ScrollView
  130. contentContainerStyle={styles.scrollContent}
  131. keyboardShouldPersistTaps="handled"
  132. showsVerticalScrollIndicator={false}
  133. >
  134. {/* 底部表单区域 */}
  135. <View style={[styles.bottom, { paddingBottom: insets.bottom + 70 }]}>
  136. {/* 表单 */}
  137. <View style={styles.form}>
  138. {/* 手机号输入 */}
  139. <View style={styles.formItem}>
  140. <Text style={styles.label}>手机号</Text>
  141. <TextInput
  142. style={styles.input}
  143. value={phone}
  144. onChangeText={setPhone}
  145. placeholder="手机号"
  146. placeholderTextColor="rgba(255,255,255,0.5)"
  147. keyboardType="phone-pad"
  148. maxLength={11}
  149. />
  150. </View>
  151. <View style={styles.divider} />
  152. {/* 验证码输入 */}
  153. <View style={styles.formItem}>
  154. <Text style={styles.label}>验证码</Text>
  155. <TextInput
  156. style={[styles.input, styles.codeInput]}
  157. value={verifyCode}
  158. onChangeText={setVerifyCode}
  159. placeholder="请填写验证码"
  160. placeholderTextColor="rgba(255,255,255,0.5)"
  161. keyboardType="number-pad"
  162. maxLength={6}
  163. />
  164. <TouchableOpacity
  165. style={[styles.verifyBtn, disabled && styles.verifyBtnDisabled]}
  166. onPress={handleGetVerifyCode}
  167. disabled={disabled}
  168. >
  169. <Text style={[styles.verifyBtnText, disabled && styles.verifyBtnTextDisabled]}>
  170. {tips}
  171. </Text>
  172. </TouchableOpacity>
  173. </View>
  174. <View style={styles.divider} />
  175. </View>
  176. {/* 协议勾选 */}
  177. <TouchableOpacity
  178. style={styles.agree}
  179. onPress={() => setAgreeFlag(!agreeFlag)}
  180. >
  181. <View style={[styles.radio, agreeFlag && styles.radioChecked]}>
  182. {agreeFlag && <View style={styles.radioInner} />}
  183. </View>
  184. <Text style={styles.agreeText}>
  185. 我已阅读并同意
  186. <Text style={styles.linkText}>《用户协议》</Text>
  187. <Text style={styles.linkText}>《隐私政策》</Text>
  188. </Text>
  189. </TouchableOpacity>
  190. {/* 按钮区域 */}
  191. <View style={styles.btnArea}>
  192. <TouchableOpacity
  193. style={[styles.btn, loading && styles.btnDisabled]}
  194. onPress={handleLogin}
  195. disabled={loading}
  196. activeOpacity={0.8}
  197. >
  198. <ImageBackground
  199. source={{ uri: Images.common.loginBtn }}
  200. style={styles.loginBtnBg}
  201. resizeMode="stretch"
  202. >
  203. <Text style={styles.btnText}>
  204. {loading ? '登录中...' : '登录'}
  205. </Text>
  206. </ImageBackground>
  207. </TouchableOpacity>
  208. <TouchableOpacity style={styles.btnBack} onPress={goBack}>
  209. <Text style={styles.btnBackText}>返回</Text>
  210. </TouchableOpacity>
  211. </View>
  212. </View>
  213. </ScrollView>
  214. </KeyboardAvoidingView>
  215. </ImageBackground>
  216. </View>
  217. );
  218. }
  219. const styles = StyleSheet.create({
  220. container: {
  221. flex: 1,
  222. backgroundColor: '#1a1a2e',
  223. },
  224. background: {
  225. flex: 1,
  226. },
  227. keyboardView: {
  228. flex: 1,
  229. },
  230. scrollContent: {
  231. flexGrow: 1,
  232. justifyContent: 'flex-end',
  233. },
  234. bottom: {
  235. width: '100%',
  236. },
  237. form: {
  238. paddingHorizontal: 25,
  239. },
  240. formItem: {
  241. flexDirection: 'row',
  242. alignItems: 'center',
  243. paddingVertical: 12,
  244. },
  245. label: {
  246. color: '#fff',
  247. fontSize: 14,
  248. width: 50,
  249. },
  250. input: {
  251. flex: 1,
  252. color: '#fff',
  253. fontSize: 14,
  254. paddingVertical: 8,
  255. outlineStyle: 'none',
  256. } as any,
  257. codeInput: {
  258. flex: 1,
  259. },
  260. divider: {
  261. height: 1,
  262. backgroundColor: 'rgba(255,255,255,0.2)',
  263. },
  264. verifyBtn: {
  265. backgroundColor: '#000',
  266. borderRadius: 4,
  267. paddingHorizontal: 10,
  268. paddingVertical: 5,
  269. minWidth: 80,
  270. alignItems: 'center',
  271. },
  272. verifyBtnDisabled: {
  273. backgroundColor: '#ccc',
  274. },
  275. verifyBtnText: {
  276. color: '#fff',
  277. fontSize: 12,
  278. },
  279. verifyBtnTextDisabled: {
  280. color: '#666',
  281. },
  282. agree: {
  283. flexDirection: 'row',
  284. alignItems: 'center',
  285. justifyContent: 'center',
  286. marginTop: 25,
  287. paddingHorizontal: 25,
  288. },
  289. radio: {
  290. width: 16,
  291. height: 16,
  292. borderRadius: 8,
  293. borderWidth: 1,
  294. borderColor: 'rgba(255,255,255,0.5)',
  295. marginRight: 6,
  296. justifyContent: 'center',
  297. alignItems: 'center',
  298. },
  299. radioChecked: {
  300. borderColor: '#8b3dff',
  301. backgroundColor: '#8b3dff',
  302. },
  303. radioInner: {
  304. width: 6,
  305. height: 6,
  306. borderRadius: 3,
  307. backgroundColor: '#fff',
  308. },
  309. agreeText: {
  310. color: '#fff',
  311. fontSize: 12,
  312. },
  313. linkText: {
  314. color: '#8b3dff',
  315. },
  316. btnArea: {
  317. paddingTop: 15,
  318. alignItems: 'center',
  319. },
  320. btn: {
  321. width: 234,
  322. height: 45,
  323. overflow: 'hidden',
  324. },
  325. loginBtnBg: {
  326. width: '100%',
  327. height: '100%',
  328. justifyContent: 'center',
  329. alignItems: 'center',
  330. },
  331. btnDisabled: {
  332. opacity: 0.6,
  333. },
  334. btnText: {
  335. color: '#fff',
  336. fontSize: 14,
  337. fontWeight: '600',
  338. },
  339. btnBack: {
  340. width: 234,
  341. height: 35,
  342. backgroundColor: '#fff',
  343. borderRadius: 25,
  344. justifyContent: 'center',
  345. alignItems: 'center',
  346. marginTop: 10,
  347. borderWidth: 1,
  348. borderColor: '#fff',
  349. },
  350. btnBackText: {
  351. color: '#888',
  352. fontSize: 12,
  353. },
  354. });