登录页面与组件样式调整

This commit is contained in:
2025-03-19 15:49:18 +08:00
parent eaae095d0e
commit 906693be10
28 changed files with 1405 additions and 206 deletions

View 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}`,
},
})
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { LinkItem, MenuItem } from './_server/navs'
import {LinkItem, MenuItem} from './_server/navs'
import SolutionMenu from './_client/solution'
import ProductMenu from './_client/product'
import HelpMenu from './_client/help'
@@ -39,9 +39,9 @@ export default function Header(props: HeaderProps) {
const [menu, setMenu] = useState(false)
const [page, setPage] = useState(0)
const pages = useMemo(() => [
<ProductMenu key={`product`} />,
<SolutionMenu key={`solution`} />,
<HelpMenu key={`help`} />,
<ProductMenu key={`product`}/>,
<SolutionMenu key={`solution`}/>,
<HelpMenu key={`help`}/>,
], [])
// ======================
@@ -53,7 +53,6 @@ export default function Header(props: HeaderProps) {
`fixed top-0 w-full z-10`,
].join(' ')}>
<div className={[
``,
`transition-[background, shadow] duration-200 ease-in-out`,
menu
? `bg-[#fffe] backdrop-blur-sm`
@@ -65,13 +64,13 @@ export default function Header(props: HeaderProps) {
<div className="flex justify-between gap-8">
{/* logo */}
<Link href="/public" className={`flex items-center`}>
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`} />
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`}/>
</Link>
{/* 菜单 */}
<nav>
<ul className="h-full flex items-stretch max-lg:hidden">
<LinkItem text={`首页`} href={`/`} />
<LinkItem text={`首页`} href={`/`}/>
<MenuItem
text={`产品订购`}
active={menu && page === 0}
@@ -106,9 +105,9 @@ export default function Header(props: HeaderProps) {
}}
/>
<LinkItem
text={`企业服务`} href={`#`} />
text={`企业服务`} href={`#`}/>
<LinkItem
text={`推广返利`} href={`#`} />
text={`推广返利`} href={`#`}/>
</ul>
</nav>
</div>
@@ -156,4 +155,3 @@ export default function Header(props: HeaderProps) {

View File

@@ -4,10 +4,6 @@
@custom-variant dark (&:is(.dark *));
body {
color: hsl(0, 0%, 10%);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
@@ -124,3 +120,7 @@ body {
@apply bg-background text-foreground;
}
}
body {
color: hsl(0, 0%, 10%);
}

View File

@@ -2,6 +2,11 @@ import {ReactNode} from 'react'
import {Metadata} from 'next'
import './globals.css'
import localFont from 'next/font/local'
import {Toaster} from '@/components/ui/sonner'
const font = localFont({
src: './NotoSansSC-VariableFont_wght.ttf',
})
export const metadata: Metadata = {
title: 'Create Next App',
@@ -15,8 +20,9 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-Cn">
<body className={`bg-blue-50`}>
<body className={`${font.className} bg-blue-50`}>
{children}
<Toaster position={'top-center'}/>
</body>
</html>
)

12
src/app/test/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import {NextRequest, NextResponse} from 'next/server'
import {cookies} from 'next/headers'
export async function GET(req: NextRequest) {
const store = await cookies()
store.set('test','test')
return NextResponse.json(JSON.stringify({
'test': 'value',
}))
}