sign-in.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { signIn, useAuthContext } from '@/utils/auth';
  2. import { Toast } from '@ant-design/react-native';
  3. import { Ionicons } from '@expo/vector-icons';
  4. import { router, useLocalSearchParams } from 'expo-router';
  5. import React, { useState } from 'react';
  6. import {
  7. KeyboardAvoidingView,
  8. Platform,
  9. Pressable,
  10. ScrollView,
  11. StyleSheet,
  12. Text,
  13. TextInput,
  14. View,
  15. } from 'react-native';
  16. import { SafeAreaView } from 'react-native-safe-area-context';
  17. function FieldLabel({ children }: { children: React.ReactNode }) {
  18. return (
  19. <Text className="mb-2 ml-1 text-xs font-bold uppercase tracking-widest text-outline">
  20. {children}
  21. </Text>
  22. );
  23. }
  24. export default function SignInScreen({
  25. callback,
  26. }: {
  27. callback?: (signin: boolean) => void;
  28. }) {
  29. const [authMode, setAuthMode] = useState<'sms' | 'password'>('sms');
  30. const [agreed, setAgreed] = useState(false);
  31. const [loading, setLoading] = useState(false);
  32. const [phone, setPhone] = useState('');
  33. const [code, setCode] = useState('');
  34. const [password, setPassword] = useState('');
  35. const { redirectTo } = useLocalSearchParams<{ redirectTo?: string }>();
  36. const { setToken } = useAuthContext();
  37. const handleSendCode = () => {
  38. if (phone.trim().length !== 11) {
  39. Toast.fail('请先输入 11 位手机号');
  40. return;
  41. }
  42. Toast.success('验证码已发送');
  43. };
  44. const handleLogin = async () => {
  45. if (!agreed) {
  46. Toast.fail('请先阅读并同意协议');
  47. callback?.(false);
  48. return;
  49. }
  50. if (phone.trim().length !== 11) {
  51. Toast.fail('请输入正确的手机号');
  52. callback?.(false);
  53. return;
  54. }
  55. if (authMode === 'sms' && code.trim().length !== 6) {
  56. Toast.fail('请输入 6 位验证码');
  57. callback?.(false);
  58. return;
  59. }
  60. if (authMode === 'password' && password.trim().length < 6) {
  61. Toast.fail('请输入不少于 6 位的密码');
  62. callback?.(false);
  63. return;
  64. }
  65. setLoading(true);
  66. const toastKey = Toast.loading('正在登录...');
  67. try {
  68. const token = await signIn(1);
  69. setToken(token);
  70. Toast.success('登录成功');
  71. callback?.(true);
  72. const nextRoute =
  73. typeof redirectTo === 'string' && redirectTo.startsWith('/')
  74. ? redirectTo
  75. : '/';
  76. router.replace(nextRoute as never);
  77. } catch (error) {
  78. console.error('登录失败:', error);
  79. Toast.fail('登录失败,请稍后重试');
  80. callback?.(false);
  81. } finally {
  82. setLoading(false);
  83. Toast.remove(toastKey);
  84. }
  85. };
  86. return (
  87. <SafeAreaView className="flex-1 bg-surface">
  88. <KeyboardAvoidingView
  89. className="flex-1"
  90. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  91. >
  92. <ScrollView
  93. className="flex-1"
  94. contentContainerClassName="px-8 pt-12 pb-10"
  95. contentContainerStyle={{ flexGrow: 1 }}
  96. keyboardShouldPersistTaps="handled"
  97. showsVerticalScrollIndicator={false}
  98. >
  99. <View className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-primary-container/10" />
  100. <View className="absolute top-32 -left-20 h-64 w-64 rounded-full bg-secondary-container/20" />
  101. <View className="absolute -bottom-16 right-0 h-48 w-48 rounded-full bg-primary-fixed/50" />
  102. <View className="flex-1 justify-between">
  103. <View>
  104. <View className="mb-12">
  105. <View className="mb-6 h-16 w-16 items-center justify-center rounded-2xl bg-primary-container shadow-lg">
  106. <Ionicons name="business" size={30} color="#ffffff" />
  107. </View>
  108. <Text className="mb-2 text-4xl font-extrabold tracking-tight text-on-surface">
  109. 欢迎回来
  110. </Text>
  111. <Text className="text-base font-medium leading-7 text-on-surface-variant">
  112. 登录贷款助手,开启您的财务管理之旅
  113. </Text>
  114. </View>
  115. <View className="mb-8 rounded-2xl bg-surface-container-lowest p-3 shadow-sm">
  116. <View className="mb-6 flex-row rounded-2xl bg-surface-container-low p-1">
  117. {[
  118. ['sms', '验证码登录'],
  119. ['password', '密码登录'],
  120. ].map(([mode, label]) => {
  121. const active = authMode === mode;
  122. return (
  123. <Pressable
  124. key={mode}
  125. disabled={loading}
  126. onPress={() => setAuthMode(mode as 'sms' | 'password')}
  127. className={`flex-1 rounded-xl px-4 py-3 ${
  128. active ? 'bg-surface-container-lowest' : ''
  129. }`}
  130. style={({ pressed }) => ({
  131. opacity: loading ? 0.5 : pressed ? 0.86 : 1,
  132. transform: [{ scale: pressed ? 0.99 : 1 }],
  133. })}
  134. >
  135. <Text
  136. className={`text-center text-base font-bold ${
  137. active ? 'text-primary' : 'text-on-surface-variant'
  138. }`}
  139. >
  140. {label}
  141. </Text>
  142. </Pressable>
  143. );
  144. })}
  145. </View>
  146. <View className="px-2 pb-2">
  147. <FieldLabel>手机号码</FieldLabel>
  148. <View className="mb-5 flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
  149. <Text className="text-2xl font-bold text-on-surface">+86</Text>
  150. <View
  151. className="mx-4 h-5 bg-outline-variant/40"
  152. style={{ width: StyleSheet.hairlineWidth }}
  153. />
  154. <TextInput
  155. value={phone}
  156. onChangeText={setPhone}
  157. editable={!loading}
  158. keyboardType="phone-pad"
  159. maxLength={11}
  160. placeholder="请输入手机号"
  161. placeholderTextColor="#9ca3af"
  162. className="flex-1 p-0 text-xl font-medium text-on-surface"
  163. />
  164. <Ionicons
  165. name="phone-portrait-outline"
  166. size={22}
  167. color="#c3c6d7"
  168. />
  169. </View>
  170. <FieldLabel>{authMode === 'sms' ? '验证码' : '密码'}</FieldLabel>
  171. <View className="flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
  172. <TextInput
  173. value={authMode === 'sms' ? code : password}
  174. onChangeText={authMode === 'sms' ? setCode : setPassword}
  175. editable={!loading}
  176. keyboardType={authMode === 'sms' ? 'number-pad' : 'default'}
  177. maxLength={authMode === 'sms' ? 6 : 20}
  178. secureTextEntry={authMode === 'password'}
  179. placeholder={
  180. authMode === 'sms'
  181. ? '请输入 6 位验证码'
  182. : '请输入登录密码'
  183. }
  184. placeholderTextColor="#9ca3af"
  185. className="flex-1 p-0 text-xl font-medium text-on-surface"
  186. />
  187. {authMode === 'sms' ? (
  188. <Pressable
  189. disabled={loading}
  190. hitSlop={8}
  191. onPress={handleSendCode}
  192. style={({ pressed }) => ({
  193. opacity: loading ? 0.5 : pressed ? 0.72 : 1,
  194. })}
  195. >
  196. <Text className="text-lg font-bold leading-6 text-primary">
  197. 获取验证码
  198. </Text>
  199. </Pressable>
  200. ) : (
  201. <Ionicons name="lock-closed-outline" size={22} color="#c3c6d7" />
  202. )}
  203. </View>
  204. </View>
  205. </View>
  206. <View className="mb-8 gap-4">
  207. <Pressable
  208. disabled={loading}
  209. onPress={handleLogin}
  210. className="items-center justify-center rounded-2xl bg-primary-container py-5 shadow-lg"
  211. style={({ pressed }) => ({
  212. opacity: loading ? 0.6 : pressed ? 0.88 : 1,
  213. transform: [{ scale: pressed ? 0.985 : 1 }],
  214. })}
  215. >
  216. <Text className="text-2xl font-extrabold text-on-primary">
  217. {loading ? '登录中...' : '立即登录'}
  218. </Text>
  219. </Pressable>
  220. <View className="flex-row items-center justify-between px-3">
  221. <Pressable hitSlop={8}>
  222. <Text className="text-lg font-medium text-on-surface-variant">
  223. 注册账号
  224. </Text>
  225. </Pressable>
  226. <Pressable hitSlop={8}>
  227. <Text className="text-lg font-medium text-on-surface-variant">
  228. 遇到问题?
  229. </Text>
  230. </Pressable>
  231. </View>
  232. </View>
  233. </View>
  234. <View className="pt-4">
  235. <View className="mb-8 flex-row items-center gap-4">
  236. <View className="h-px flex-1 bg-surface-container-highest" />
  237. <Text className="text-sm font-bold tracking-widest text-outline">
  238. 其他方式登录
  239. </Text>
  240. <View className="h-px flex-1 bg-surface-container-highest" />
  241. </View>
  242. <View className="mb-10 flex-row justify-center gap-10">
  243. {[
  244. ['chatbubble-ellipses-outline', '#6b7280'],
  245. ['logo-apple', '#6b7280'],
  246. ].map(([icon, color]) => (
  247. <Pressable
  248. key={icon}
  249. className="h-14 w-14 items-center justify-center rounded-full bg-surface-container-low"
  250. style={({ pressed }) => ({
  251. opacity: pressed ? 0.8 : 1,
  252. transform: [{ scale: pressed ? 0.94 : 1 }],
  253. })}
  254. >
  255. <Ionicons
  256. name={icon as keyof typeof Ionicons.glyphMap}
  257. size={24}
  258. color={color}
  259. />
  260. </Pressable>
  261. ))}
  262. </View>
  263. <View className="flex-row items-start gap-3 px-2">
  264. <Pressable
  265. onPress={() => setAgreed((value) => !value)}
  266. hitSlop={8}
  267. className="pt-1"
  268. >
  269. <View
  270. className={`h-6 w-6 items-center justify-center rounded-full border ${
  271. agreed
  272. ? 'border-primary bg-primary'
  273. : 'border-outline-variant bg-surface-container-low'
  274. }`}
  275. >
  276. {agreed ? (
  277. <Ionicons name="checkmark" size={14} color="#ffffff" />
  278. ) : null}
  279. </View>
  280. </Pressable>
  281. <Text className="flex-1 text-sm leading-7 text-on-surface-variant">
  282. 登录即代表您已阅读并同意
  283. <Text className="font-semibold text-primary">《用户服务协议》</Text>
  284. <Text className="font-semibold text-primary">《隐私政策》</Text>
  285. ,以及授权该应用获取您的公开信息。
  286. </Text>
  287. </View>
  288. </View>
  289. </View>
  290. </ScrollView>
  291. </KeyboardAvoidingView>
  292. </SafeAreaView>
  293. );
  294. }