返回 AiToEarn
PhoneLoginForm.tsx
根目录 / project / aitoearn-web / src / app / [lng] / auth / login / components / LoginContent / PhoneLoginForm.tsx
1 /**
2 * PhoneLoginForm - 手机号验证码登录表单(国内环境)
3 */
4
5 'use client'
6
7 import { zodResolver } from '@hookform/resolvers/zod'
8 import { Loader2 } from 'lucide-react'
9 import { useRouter, useSearchParams } from 'next/navigation'
10 import { useMemo, useState } from 'react'
11 import { useForm } from 'react-hook-form'
12 import { z } from 'zod'
13
14 import { phoneCodeLoginApi, sendPhoneCodeApi } from '@/api/auth/auth.api'
15 import { useTransClient } from '@/app/i18n/client'
16 import { Button } from '@/components/ui/button'
17 import { Input } from '@/components/ui/input'
18 import { useUserStore } from '@/store/user'
19 import { toast } from '@/utils/ui/toast'
20
21 import { useCountdown } from './useCountdown'
22
23 interface PhoneLoginFormProps {
24 /** 弹框模式:登录成功回调,替代 router.push */
25 onLoginSuccess?: () => void
26 /** 覆盖 searchParams 的 redirect */
27 redirectUrl?: string
28 /** 覆盖 searchParams 的 inviteCode */
29 inviteCode?: string
30 }
31
32 interface PhoneLoginFormData {
33 phone: string
34 code: string
35 }
36
37 export function PhoneLoginForm({ onLoginSuccess, redirectUrl, inviteCode: _inviteCodeProp }: PhoneLoginFormProps = {}) {
38 const router = useRouter()
39 const searchParams = useSearchParams()
40 const redirect = redirectUrl ?? searchParams.get('redirect')
41 const { setToken, setUserInfo } = useUserStore()
42 const { t } = useTransClient('login')
43 const { countdown, isCounting, start: startCountdown } = useCountdown()
44 const [sendingCode, setSendingCode] = useState(false)
45
46 const schema = useMemo(
47 () =>
48 z.object({
49 phone: z
50 .string()
51 .min(1, t('phoneRequired'))
52 .regex(/^1[3-9]\d{9}$/, t('phoneInvalid')),
53 code: z.string().min(1, t('emailCodeRequired')).length(6, t('codeLength')),
54 }),
55 [t],
56 )
57
58 const form = useForm<PhoneLoginFormData>({
59 resolver: zodResolver(schema),
60 defaultValues: { phone: '', code: '' },
61 })
62
63 /** 发送手机验证码 */
64 const handleSendCode = async () => {
65 const result = await form.trigger('phone')
66 if (!result)
67 return
68
69 const phone = form.getValues('phone')
70 setSendingCode(true)
71 try {
72 const res = await sendPhoneCodeApi({ phone })
73 if (res?.code === 0) {
74 toast.success(t('codeSentSuccess'))
75 startCountdown()
76 }
77 else {
78 toast.error(res?.message || t('codeSendFailed'))
79 }
80 }
81 catch {
82 toast.error(t('codeSendFailed'))
83 }
84 finally {
85 setSendingCode(false)
86 }
87 }
88
89 /** 手机验证码登录 */
90 const handleSubmit = async (data: PhoneLoginFormData) => {
91 try {
92 const res = await phoneCodeLoginApi({ phone: data.phone, code: data.code })
93 if (!res)
94 return
95
96 if (res.code === 0 && res.data.token) {
97 setToken(res.data.token)
98 if (res.data.userInfo) {
99 setUserInfo(res.data.userInfo)
100 }
101 toast.success(t('loginSuccess'))
102 if (onLoginSuccess) {
103 onLoginSuccess()
104 }
105 else {
106 router.push(redirect || '/')
107 }
108 }
109 else {
110 toast.error(res.message || t('loginFailed'))
111 }
112 }
113 catch {
114 toast.error(t('loginError'))
115 }
116 }
117
118 return (
119 <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
120 <div>
121 <div className="flex">
122 <span className="inline-flex h-12 items-center rounded-l-xl border border-r-0 border-input bg-muted px-3 text-sm text-muted-foreground">
123 +86
124 </span>
125 <Input
126 type="tel"
127 inputMode="numeric"
128 maxLength={11}
129 placeholder={t('phonePlaceholder')}
130 {...form.register('phone')}
131 className="h-12 rounded-l-none rounded-r-xl border-input bg-background px-4 text-base placeholder:text-muted-foreground/70 focus:border-ring focus:ring-0"
132 />
133 </div>
134 {form.formState.errors.phone && (
135 <p className="mt-1 text-xs text-destructive">
136 {form.formState.errors.phone.message}
137 </p>
138 )}
139 </div>
140
141 <div className="flex gap-2">
142 <div className="flex-1">
143 <Input
144 type="text"
145 inputMode="numeric"
146 maxLength={6}
147 autoComplete="off"
148 placeholder={t('enterCode')}
149 {...form.register('code')}
150 className="h-12 rounded-xl border-input bg-background px-4 text-base placeholder:text-muted-foreground/70 focus:border-ring focus:ring-0"
151 />
152 {form.formState.errors.code && (
153 <p className="mt-1 text-xs text-destructive">
154 {form.formState.errors.code.message}
155 </p>
156 )}
157 </div>
158 <Button
159 type="button"
160 variant="outline"
161 disabled={isCounting || sendingCode}
162 onClick={handleSendCode}
163 className="h-12 shrink-0 cursor-pointer rounded-xl px-4"
164 >
165 {sendingCode ? (
166 <Loader2 className="h-4 w-4 animate-spin" />
167 ) : isCounting ? (
168 `${countdown}s`
169 ) : (
170 t('sendCode')
171 )}
172 </Button>
173 </div>
174
175 <Button
176 type="submit"
177 disabled={form.formState.isSubmitting}
178 className="h-12 w-full cursor-pointer rounded-xl text-base font-medium"
179 >
180 {form.formState.isSubmitting ? (
181 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
182 ) : (
183 t('login')
184 )}
185 </Button>
186 </form>
187 )
188 }
189
189 lines Plain Text