升级依赖版本并修复构建问题

This commit is contained in:
2025-11-20 12:10:16 +08:00
parent fa6a4e5121
commit c02ffc9983
26 changed files with 669 additions and 649 deletions

View File

@@ -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',

View File

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

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

View File

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