开启 ppr 优化渲染性能
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
'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 sans-serif'
|
||||
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()
|
||||
console.log('生成验证码:', captchaText)
|
||||
|
||||
// 生成验证码图像
|
||||
const captchaImage = generateCaptchaImage(captchaText)
|
||||
|
||||
// 生成验证码哈希和盐值
|
||||
const {hash, salt} = hashCaptcha(captchaText)
|
||||
const store = await cookies()
|
||||
const coo = store
|
||||
.set('captcha_hash', hash, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60,
|
||||
})
|
||||
.set('captcha_salt', salt, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60,
|
||||
})
|
||||
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(captchaImage)
|
||||
controller.close()
|
||||
},
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'no-store',
|
||||
'Set-Cookie': `${coo}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
8
src/app/(auth)/challenge/route.ts
Normal file
8
src/app/(auth)/challenge/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {getCap} from '@/lib/cap'
|
||||
import {NextResponse} from 'next/server'
|
||||
|
||||
export async function POST() {
|
||||
const cap = await getCap()
|
||||
const challenge = await cap.createChallenge()
|
||||
return NextResponse.json(challenge)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
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 = {
|
||||
}
|
||||
|
||||
export default function Captcha(props: CaptchaProps) {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
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 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('验证码已发送')
|
||||
}
|
||||
catch (e) {
|
||||
refreshCaptcha()
|
||||
toast.error('验证码发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (countdown > 0) {
|
||||
setCountdown(countdown - 1)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogDescription>请在下方输入验证码</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Image
|
||||
unoptimized
|
||||
src={url}
|
||||
alt="验证码"
|
||||
width={180}
|
||||
height={50}
|
||||
className="border cursor-pointer"
|
||||
onClick={refreshCaptcha}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={refreshCaptcha}
|
||||
className="text-sm"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入图形验证码"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShow(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={sendCode}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -7,15 +7,16 @@ 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 {useState, ReactNode} from 'react'
|
||||
import {useState, ReactNode, useEffect, Suspense} from 'react'
|
||||
import zod from 'zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {useForm, useFormContext, useWatch} 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'
|
||||
import {login, LoginMode} from '@/actions/auth'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
import SendMsg from '@/components/send-msg'
|
||||
import '@cap.js/widget'
|
||||
|
||||
const smsSchema = zod.object({
|
||||
username: zod.string().length(11, '请输入正确的手机号码'),
|
||||
@@ -31,15 +32,23 @@ const pwdSchema = zod.object({
|
||||
|
||||
export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
|
||||
|
||||
export default function LoginCard(props: {
|
||||
defaultMode?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}) {
|
||||
export default function LoginCard() {
|
||||
const router = useRouter()
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
const [mode, setMode] = useState(props.defaultMode || 'phone_code')
|
||||
const [mode, setMode] = useState<LoginMode>('phone_code')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const updateLoginMode = (mode: LoginMode) => {
|
||||
sessionStorage.setItem('login_mode', mode)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const mode = sessionStorage.getItem('login_mode')
|
||||
if (mode) {
|
||||
setMode(mode as LoginMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const form = useForm<LoginSchema>({
|
||||
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
||||
defaultValues: {
|
||||
@@ -56,9 +65,12 @@ export default function LoginCard(props: {
|
||||
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
// 登录成功
|
||||
await refreshProfile()
|
||||
router.push(props.redirect || '/')
|
||||
updateLoginMode(mode)
|
||||
router.push(params.get('redirect') || '/')
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
@@ -114,7 +126,7 @@ export default function LoginCard(props: {
|
||||
placeholder="请输入验证码"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<Captcha/>
|
||||
<SendMsgByUsername/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
@@ -193,3 +205,9 @@ function Tab(props: {
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SendMsgByUsername() {
|
||||
const form = useFormContext<LoginSchema>()
|
||||
const phone = form.watch('username')
|
||||
return <SendMsg phone={phone}/>
|
||||
}
|
||||
|
||||
@@ -5,16 +5,9 @@ import bg from './_assets/bg.webp'
|
||||
import Link from 'next/link'
|
||||
import LoginCard from './login-card'
|
||||
|
||||
export type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
type?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}>
|
||||
}
|
||||
export type LoginPageProps = {}
|
||||
|
||||
export default async function LoginPage(props: LoginPageProps) {
|
||||
const searchParams = await props.searchParams
|
||||
|
||||
return (
|
||||
<main className={merge(
|
||||
`relative`,
|
||||
@@ -27,10 +20,7 @@ export default async function LoginPage(props: LoginPageProps) {
|
||||
</Link>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<LoginCard
|
||||
defaultMode={searchParams.type}
|
||||
redirect={searchParams.redirect}
|
||||
/>
|
||||
<LoginCard/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
9
src/app/(auth)/redeem/route.ts
Normal file
9
src/app/(auth)/redeem/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {NextRequest, NextResponse} from 'next/server'
|
||||
import {getCap} from '@/lib/cap'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const cap = await getCap()
|
||||
const result = await cap.redeemChallenge(body)
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
Reference in New Issue
Block a user