升级依赖版本并修复构建问题
This commit is contained in:
@@ -81,7 +81,12 @@ export async function GET(request: Request) {
|
||||
maxAge: 60,
|
||||
})
|
||||
|
||||
return new Response(captchaImage, {
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(captchaImage)
|
||||
controller.close()
|
||||
},
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'no-store',
|
||||
|
||||
@@ -1,51 +1,98 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import Image from 'next/image'
|
||||
import {useFormContext} from 'react-hook-form'
|
||||
import {LoginSchema} from './login-card'
|
||||
import {toast} from 'sonner'
|
||||
import {sendSMS} from '@/actions/verify'
|
||||
|
||||
const seed = () => Date.now()
|
||||
|
||||
export type CaptchaProps = {
|
||||
showCaptcha: boolean
|
||||
setShowCaptcha: (show: boolean) => void
|
||||
handleSendCode: (captchaCode: string) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
export default function Captcha(props: CaptchaProps) {
|
||||
const {showCaptcha, setShowCaptcha, handleSendCode} = props
|
||||
const [captchaImage, setCaptchaImage] = useState('/captcha?t=' + Date.now())
|
||||
const [captchaCode, setCaptchaCode] = useState('')
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 刷新图形验证码
|
||||
const refreshCaptcha = useCallback(() => {
|
||||
setCaptchaImage('/captcha?t=' + Date.now())
|
||||
setCaptchaCode('')
|
||||
}, [])
|
||||
const [code, setCode] = useState('')
|
||||
const [url, setUrl] = useState('/captcha?t=' + seed())
|
||||
const [show, setShow] = useState(false)
|
||||
const form = useFormContext<LoginSchema>()
|
||||
const username = form.watch('username')
|
||||
|
||||
const handleVerifyCaptcha = useCallback(async () => {
|
||||
let refresh = handleSendCode(captchaCode)
|
||||
if (refresh instanceof Promise) {
|
||||
refresh = await refresh
|
||||
const setShowWithCheck = async (value: boolean) => {
|
||||
setShow(value && await form.trigger('username'))
|
||||
}
|
||||
|
||||
const refreshCaptcha = () => {
|
||||
setUrl('/captcha?t=' + seed())
|
||||
setCode('')
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
if (!code.length) {
|
||||
throw new Error('请输入图形验证码')
|
||||
}
|
||||
|
||||
const valid = await form.trigger('username')
|
||||
if (!valid) {
|
||||
throw new Error('请输入正确的手机号')
|
||||
}
|
||||
|
||||
const resp = await sendSMS({
|
||||
phone: username,
|
||||
captcha: code,
|
||||
})
|
||||
if (!resp.success) {
|
||||
throw new Error(`验证码发送失败: ${resp.message}`)
|
||||
}
|
||||
|
||||
setCountdown(60)
|
||||
setShow(false)
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
if (refresh) {
|
||||
catch (e) {
|
||||
refreshCaptcha()
|
||||
toast.error('验证码发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}, [captchaCode, handleSendCode, refreshCaptcha])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showCaptcha) {
|
||||
refreshCaptcha()
|
||||
}
|
||||
}, [showCaptcha, refreshCaptcha])
|
||||
const interval = setInterval(() => {
|
||||
if (countdown > 0) {
|
||||
setCountdown(countdown - 1)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
return (
|
||||
<Dialog open={showCaptcha} onOpenChange={setShowCaptcha}>
|
||||
<Dialog open={show} onOpenChange={setShowWithCheck}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="whitespace-nowrap h-10"
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>请完成图形验证</DialogTitle>
|
||||
<DialogTitle>人机验证</DialogTitle>
|
||||
<DialogDescription>请在下方输入验证码</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<img
|
||||
src={captchaImage}
|
||||
<Image
|
||||
unoptimized
|
||||
src={url}
|
||||
alt="验证码"
|
||||
width={180}
|
||||
height={50}
|
||||
@@ -62,22 +109,22 @@ export default function Captcha(props: CaptchaProps) {
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入图形验证码"
|
||||
value={captchaCode}
|
||||
onChange={e => setCaptchaCode(e.target.value)}
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShowCaptcha(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCaptcha}
|
||||
>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShow(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={sendCode}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
187
src/app/(auth)/login/login-card.tsx
Normal file
187
src/app/(auth)/login/login-card.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Checkbox} from '@/components/ui/checkbox'
|
||||
import {Card, CardHeader, CardContent, CardTitle} from '@/components/ui/card'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import {EyeClosedIcon, EyeIcon} from 'lucide-react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import zod from 'zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {toast} from 'sonner'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {login} from '@/actions/auth'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
import Captcha from './captcha'
|
||||
|
||||
const smsSchema = zod.object({
|
||||
username: zod.string().length(11, '请输入正确的手机号码'),
|
||||
password: zod.string().length(6, '请输入正确的验证码'),
|
||||
remember: zod.boolean(),
|
||||
})
|
||||
|
||||
const pwdSchema = zod.object({
|
||||
username: zod.string(),
|
||||
password: zod.string().min(6, '请输入至少6位密码'),
|
||||
remember: zod.boolean(),
|
||||
})
|
||||
|
||||
export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
|
||||
|
||||
export default function LoginCard(props: {
|
||||
defaultMode?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
const [mode, setMode] = useState(props.defaultMode || 'phone_code')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<LoginSchema>({
|
||||
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
},
|
||||
})
|
||||
const handler = form.handleSubmit(async (data) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await login({...data, mode})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
await refreshProfile()
|
||||
router.push(props.redirect || '/')
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('登录失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
|
||||
return (
|
||||
<Card className="w-96 mx-4 shadow-lg relative z-20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">登录/注册</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8">
|
||||
{/* 登录方式切换 */}
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(val) => {
|
||||
setMode(val as typeof mode)
|
||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
||||
}}
|
||||
className="mb-6">
|
||||
<TabsList className="w-full h-10 flex justify-center gap-2">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="phone_code" className="flex-1">
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
|
||||
<FormField name="username" label={mode === 'phone_code' ? '手机号' : '用户名'}>
|
||||
{({id, field}) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
autoComplete="tel-national"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="password" label={mode === 'phone_code' ? '验证码' : '密码'}>
|
||||
{({id, field}) =>
|
||||
mode === 'phone_code' ? (
|
||||
<div className="flex space-x-4">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
className="h-10"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Captcha/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
className="h-10 pr-10"
|
||||
placeholder="至少6位密码,需包含字母和数字"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPwd(v => !v)}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPwd ? (
|
||||
<EyeIcon size={20}/>
|
||||
) : (
|
||||
<EyeClosedIcon size={20}/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
<FormField name="remember">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<Label>保持登录</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
type="submit"
|
||||
theme="gradient"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : (mode === 'phone_code' ? '首次登录即注册' : '立即登录')}
|
||||
</Button>
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
登录即表示您同意
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">《用户协议》</a>
|
||||
和
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">《隐私政策》</a>
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +1,19 @@
|
||||
'use client'
|
||||
import {useState, useCallback, useRef, useEffect} from 'react'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Checkbox} from '@/components/ui/checkbox'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
} from '@/components/ui/form'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import zod from 'zod'
|
||||
import Captcha from './captcha'
|
||||
import {login} from '@/actions/auth'
|
||||
import {sendSMS} from '@/actions/verify'
|
||||
import {useRouter, useSearchParams} from 'next/navigation'
|
||||
import {toast} from 'sonner'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import bg from './_assets/bg.webp'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
import Link from 'next/link'
|
||||
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import {EyeClosedIcon, EyeIcon} from 'lucide-react'
|
||||
import LoginCard from './login-card'
|
||||
|
||||
export type LoginPageProps = {}
|
||||
export type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
type?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}>
|
||||
}
|
||||
|
||||
const smsSchema = zod.object({
|
||||
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
|
||||
password: zod.string().min(1, '请输入验证码'),
|
||||
remember: zod.boolean().default(false),
|
||||
})
|
||||
|
||||
const pwdSchema = zod.object({
|
||||
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
|
||||
password: zod.string().min(6, '请输入至少6位密码'),
|
||||
remember: zod.boolean().default(false),
|
||||
})
|
||||
|
||||
type FormValues = zod.infer<typeof smsSchema>
|
||||
|
||||
export default function LoginPage(props: LoginPageProps) {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [showCaptcha, setShowCaptcha] = useState(false)
|
||||
const [loginMode, setLoginMode] = useState<'sms' | 'password'>('password')
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const type = params.get('type')
|
||||
if (type === 'sms') {
|
||||
setLoginMode('sms')
|
||||
}
|
||||
else {
|
||||
setLoginMode('password')
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(loginMode === 'sms' ? smsSchema : pwdSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 获取表单值的快捷方式
|
||||
const username = form.watch('username')
|
||||
|
||||
// 处理短信验证码发送前的验证
|
||||
const checkUsername = useCallback(() => {
|
||||
if (!username || username.length !== 11) {
|
||||
form.setError('username', {
|
||||
type: 'manual',
|
||||
message: '请输入正确的手机号码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 显示图形验证码
|
||||
setShowCaptcha(true)
|
||||
}, [username, form])
|
||||
|
||||
// 验证图形验证码并发送短信验证码
|
||||
const sendCode = useCallback(async (captchaCode: string) => {
|
||||
if (!captchaCode) {
|
||||
toast.error('请输入图形验证码')
|
||||
return false
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
let resp: ApiResponse
|
||||
try {
|
||||
resp = await sendSMS({
|
||||
phone: username,
|
||||
captcha: captchaCode,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('短信发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理验证码发送结果
|
||||
let waiting = 60
|
||||
if (!resp.success) {
|
||||
if (resp.status != 429) {
|
||||
toast.error(resp.message)
|
||||
return true
|
||||
}
|
||||
setShowCaptcha(false)
|
||||
waiting = parseInt(resp.message)
|
||||
console.log(resp.message)
|
||||
toast.error('发送频率过快', {
|
||||
description: '请稍后再试',
|
||||
})
|
||||
}
|
||||
else {
|
||||
setShowCaptcha(false)
|
||||
toast.success('验证码已发送', {
|
||||
description: '请注意查收短信',
|
||||
})
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(waiting)
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return false
|
||||
}, [username])
|
||||
|
||||
// 处理表单提交
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
// 密码登录时增加更严格的校验
|
||||
if (loginMode === 'password') {
|
||||
const pwd = values.password || ''
|
||||
// 至少6位,包含字母和数字
|
||||
if (pwd.length < 6) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: '密码长度至少6位',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!/[A-Za-z]/.test(pwd) || !/[0-9]/.test(pwd)) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: '密码需包含字母和数字',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
|
||||
// 验证表单数据
|
||||
if (values.username?.length !== 11) {
|
||||
form.setError('username', {
|
||||
type: 'manual',
|
||||
message: '请输入有效的手机号码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: loginMode === 'sms' ? '请输入验证码' : '请输入密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用登录函数
|
||||
const result = await login({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
remember: values.remember,
|
||||
mode: loginMode === 'sms' ? 'phone_code' : 'password', // 后端区分登录方式
|
||||
})
|
||||
|
||||
// 登录失败
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
await refreshProfile()
|
||||
router.push(redirect || '/')
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('登录失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// 重定向
|
||||
// ======================
|
||||
|
||||
const redirect = params.get('redirect')
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
|
||||
// ======================
|
||||
// render
|
||||
// ======================
|
||||
export default async function LoginPage(props: LoginPageProps) {
|
||||
const searchParams = await props.searchParams
|
||||
|
||||
return (
|
||||
<main className={merge(
|
||||
@@ -248,129 +27,10 @@ export default function LoginPage(props: LoginPageProps) {
|
||||
</Link>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Card className="w-96 mx-4 shadow-lg relative z-20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">登录/注册</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8">
|
||||
{/* 登录方式切换 */}
|
||||
<Tabs
|
||||
value={loginMode}
|
||||
onValueChange={(val) => {
|
||||
setLoginMode(val as 'sms' | 'password')
|
||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
||||
}}
|
||||
className="mb-6">
|
||||
<TabsList className="w-full h-10 flex justify-center gap-2">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sms" className="flex-1">
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Form<FormValues> className="space-y-6" onSubmit={onSubmit} form={form}>
|
||||
<FormField name="username" label={loginMode === 'sms' ? '手机号' : '用户名'}>
|
||||
{({id, field}) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
autoComplete="tel-national"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="password" label={loginMode === 'sms' ? '验证码' : '密码'}>
|
||||
{({id, field}) =>
|
||||
loginMode === 'sms' ? (
|
||||
<div className="flex space-x-4">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
className="h-10"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="whitespace-nowrap h-10"
|
||||
onClick={checkUsername}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
className="h-10 pr-10"
|
||||
placeholder="至少6位密码,需包含字母和数字"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPwd(v => !v)}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPwd ? (
|
||||
<EyeIcon size={20}/>
|
||||
) : (
|
||||
<EyeClosedIcon size={20}/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
<FormField name="remember">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<Label>保持登录</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
type="submit"
|
||||
theme="gradient"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : (loginMode === 'sms' ? '首次登录即注册' : '立即登录')}
|
||||
</Button>
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
登录即表示您同意
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">《用户协议》</a>
|
||||
和
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">《隐私政策》</a>
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 图形验证码弹窗 */}
|
||||
{loginMode === 'sms' && (
|
||||
<Captcha
|
||||
showCaptcha={showCaptcha}
|
||||
setShowCaptcha={setShowCaptcha}
|
||||
handleSendCode={sendCode}
|
||||
/>
|
||||
)}
|
||||
<LoginCard
|
||||
defaultMode={searchParams.type}
|
||||
redirect={searchParams.redirect}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user