登录页面与组件样式调整
This commit is contained in:
92
src/app/(auth)/captcha/route.ts
Normal file
92
src/app/(auth)/captcha/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
'use server'
|
||||
import {createCanvas} from 'canvas'
|
||||
import crypto from 'crypto'
|
||||
import {cookies} from 'next/headers'
|
||||
|
||||
// 生成随机验证码
|
||||
function generateCaptchaText(length: number = 4): string {
|
||||
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 哈希验证码文本并使用随机盐值
|
||||
function hashCaptcha(text: string): { hash: string; salt: string } {
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
const hash = crypto
|
||||
.createHmac('sha256', salt)
|
||||
.update(text.toLowerCase())
|
||||
.digest('hex')
|
||||
return {hash, salt}
|
||||
}
|
||||
|
||||
// 生成验证码图片
|
||||
function generateCaptchaImage(text: string) {
|
||||
const canvas = createCanvas(180, 50)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 设置背景色
|
||||
ctx.fillStyle = '#f3f4f6'
|
||||
ctx.fillRect(0, 0, 180, 50)
|
||||
|
||||
// 绘制干扰线
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.strokeStyle = `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(Math.random() * 180, Math.random() * 50)
|
||||
ctx.lineTo(Math.random() * 180, Math.random() * 50)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 绘制文本
|
||||
ctx.font = '28px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 随机文本颜色和位置
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
ctx.fillStyle = `rgb(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100})`
|
||||
ctx.fillText(
|
||||
text[i],
|
||||
(180 / text.length) * (i + 0.5), // 均匀分布
|
||||
25 + Math.random() * 10 - 5, // 中间位置上下浮动
|
||||
)
|
||||
}
|
||||
|
||||
return canvas.toBuffer('image/png')
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const captchaText = generateCaptchaText()
|
||||
|
||||
// 生成验证码图像
|
||||
const captchaImage = generateCaptchaImage(captchaText)
|
||||
|
||||
// 生成验证码哈希和盐值
|
||||
const {hash, salt} = hashCaptcha(captchaText)
|
||||
const store = await cookies()
|
||||
const coo = store
|
||||
.set('captcha_hash', hash, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60,
|
||||
})
|
||||
.set('captcha_salt', salt, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60,
|
||||
})
|
||||
|
||||
return new Response(captchaImage, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'no-store',
|
||||
'Set-Cookie': `${coo}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
87
src/app/(auth)/login/captcha.tsx
Normal file
87
src/app/(auth)/login/captcha.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Input} from '@/components/ui/input'
|
||||
|
||||
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 refreshCaptcha = useCallback(() => {
|
||||
setCaptchaImage('/captcha?t=' + Date.now())
|
||||
setCaptchaCode('')
|
||||
}, [])
|
||||
|
||||
const handleVerifyCaptcha = useCallback(async () => {
|
||||
let refresh = handleSendCode(captchaCode)
|
||||
if (refresh instanceof Promise) {
|
||||
refresh = await refresh
|
||||
}
|
||||
if (refresh) {
|
||||
refreshCaptcha()
|
||||
}
|
||||
}, [captchaCode, handleSendCode, refreshCaptcha])
|
||||
|
||||
useEffect(() => {
|
||||
if (showCaptcha) {
|
||||
refreshCaptcha()
|
||||
}
|
||||
}, [showCaptcha, refreshCaptcha])
|
||||
|
||||
return (
|
||||
<Dialog open={showCaptcha} onOpenChange={setShowCaptcha}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>请完成图形验证</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<img
|
||||
src={captchaImage}
|
||||
alt="验证码"
|
||||
width={180}
|
||||
height={50}
|
||||
className="border cursor-pointer"
|
||||
onClick={refreshCaptcha}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshCaptcha}
|
||||
className="text-sm"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入图形验证码"
|
||||
value={captchaCode}
|
||||
onChange={(e) => setCaptchaCode(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCaptcha(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleVerifyCaptcha()}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +1,301 @@
|
||||
'use client'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import {useState, useCallback, useRef} 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 { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import logo from '@/assets/logo.webp'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} 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 verify from '@/actions/auth/verify'
|
||||
import {login} from '@/actions/auth/login'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {toast} from 'sonner'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
|
||||
export type LoginPageProps = {}
|
||||
|
||||
export default function LoginPage(props: LoginPageProps) {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
// 定义表单验证模式
|
||||
const formSchema = zod.object({
|
||||
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
|
||||
password: zod.string().min(1, '请输入验证码'),
|
||||
remember: zod.boolean().default(false),
|
||||
})
|
||||
type FormValues = zod.infer<typeof formSchema>
|
||||
|
||||
const handleSendCode = () => {
|
||||
// 这里实现发送验证码的逻辑
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
export default function LoginPage(props: LoginPageProps) {
|
||||
const router = useRouter()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [showCaptcha, setShowCaptcha] = useState(false)
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
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
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const resp = await verify({
|
||||
phone: username,
|
||||
captcha: captchaCode,
|
||||
})
|
||||
|
||||
// 处理验证码发送结果
|
||||
let waiting = 60
|
||||
if (!resp.success) {
|
||||
if (resp.status == 429) {
|
||||
setShowCaptcha(false)
|
||||
waiting = parseInt(resp.message)
|
||||
console.log(resp.message)
|
||||
toast.error('发送频率过快', {
|
||||
description: '请稍后再试',
|
||||
})
|
||||
}
|
||||
else {
|
||||
toast.error(resp.message)
|
||||
return true
|
||||
}
|
||||
}
|
||||
else {
|
||||
setShowCaptcha(false)
|
||||
toast.success('验证码已发送', {
|
||||
description: '请注意查收短信',
|
||||
})
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(waiting)
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
clearInterval(timerRef.current)
|
||||
return 0
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return false
|
||||
}, [username])
|
||||
|
||||
const setWaiting = (resp: ApiResponse<undefined>) => {
|
||||
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
|
||||
// 验证表单数据
|
||||
if (values.username?.length !== 11) {
|
||||
form.setError('username', {
|
||||
type: 'manual',
|
||||
message: '请输入有效的手机号码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: '请输入验证码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用登录函数
|
||||
const result = await login({
|
||||
username: values.username,
|
||||
password: values.password, // 使用验证码作为密码
|
||||
remember: values.remember,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功
|
||||
toast.success('登陆成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
|
||||
// 跳转到首页或用户仪表板
|
||||
router.push('/')
|
||||
router.refresh() // 刷新页面状态
|
||||
}
|
||||
else {
|
||||
// 登录失败
|
||||
toast.error(result.message, {
|
||||
description: '请检查您的手机号码和验证码',
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('服务器错误', {
|
||||
description: '请稍后再试',
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<main className="h-screen w-screen lg:pr-80 bg-[url(/login/bg.webp)] bg-cover bg-left flex justify-center lg:justify-end items-center">
|
||||
<main className={merge(
|
||||
`relative`,
|
||||
`h-screen w-screen xl:pr-64 bg-[url(/login/bg.webp)] bg-cover bg-left`,
|
||||
`flex justify-center xl:justify-end items-center`,
|
||||
)}>
|
||||
<Image src={logo} alt={`logo`} height={64} className={`absolute top-8 left-8`}/>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<div className="w-96 mx-4 p-8 lg:p-12 bg-white rounded-lg flex items-center justify-center">
|
||||
<div className="w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl text-gray-900">
|
||||
登录/注册
|
||||
</h2>
|
||||
</div>
|
||||
<Card className="w-96 mx-4 shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">登录/注册</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<form className="mt-8 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号码"
|
||||
autoComplete="tel"
|
||||
required
|
||||
/>
|
||||
<CardContent className={`px-8`}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>手机号码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="tel"
|
||||
placeholder="请输入手机号码"
|
||||
autoComplete="tel-national"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>验证码</FormLabel>
|
||||
<div className="flex space-x-4">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="h-12"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="whitespace-nowrap h-12"
|
||||
onClick={checkUsername}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remember"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>保持登录</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
type="submit"
|
||||
variant="gradient"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : '注册 / 登录'}
|
||||
</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>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verificationCode">验证码</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="verificationCode"
|
||||
name="verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
onClick={handleSendCode}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 图形验证码弹窗 */}
|
||||
<Captcha
|
||||
showCaptcha={showCaptcha}
|
||||
setShowCaptcha={setShowCaptcha}
|
||||
handleSendCode={sendCode}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="remember-me" name="remember-me" />
|
||||
<label htmlFor="remember-me" className="text-sm text-gray-900">
|
||||
保持登录
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col gap-2`}>
|
||||
<Button type="submit" className="w-full">
|
||||
注册 / 登录
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user