15 changed files with 834 additions and 3 deletions
@ -0,0 +1,278 @@ |
|||
import { useState } from 'react'; |
|||
import { z } from 'zod'; |
|||
|
|||
// 定义表单验证规则
|
|||
const registerSchema = z.object({ |
|||
username: z.string() |
|||
.min(3, '用户名长度不能少于3个字符') |
|||
.max(50, '用户名长度不能超过50个字符') |
|||
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'), |
|||
email: z.string() |
|||
.min(1, '邮箱不能为空') |
|||
.max(256, '邮箱长度不能超过256个字符') |
|||
.email('邮箱格式不正确'), |
|||
password: z.string() |
|||
.min(6, '密码长度不能少于6个字符') |
|||
.max(50, '密码长度不能超过50个字符') |
|||
.regex(/[A-Z]/, '密码必须包含至少一个大写字母') |
|||
.regex(/[a-z]/, '密码必须包含至少一个小写字母') |
|||
.regex(/[0-9]/, '密码必须包含至少一个数字') |
|||
.regex(/[^a-zA-Z0-9]/, '密码必须包含至少一个特殊字符'), |
|||
confirmPassword: z.string() |
|||
.min(1, '确认密码不能为空'), |
|||
phoneNumber: z.string() |
|||
.regex(/^1[3-9]\d{9}$/, '电话号码格式不正确') |
|||
.optional() |
|||
}).refine((data) => data.password === data.confirmPassword, { |
|||
message: '两次输入的密码不一致', |
|||
path: ['confirmPassword'], |
|||
}); |
|||
|
|||
interface RegisterFormProps { |
|||
onSubmit: (username: string, email: string, password: string, phoneNumber?: string) => Promise<void>; |
|||
} |
|||
|
|||
export function RegisterForm({ onSubmit }: RegisterFormProps) { |
|||
const [formData, setFormData] = useState({ |
|||
username: '', |
|||
email: '', |
|||
password: '', |
|||
confirmPassword: '', |
|||
phoneNumber: '', |
|||
}); |
|||
const [errors, setErrors] = useState<Record<string, string>>({}); |
|||
const [isLoading, setIsLoading] = useState(false); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
// 密码强度计算
|
|||
const calculatePasswordStrength = (password: string): number => { |
|||
let strength = 0; |
|||
if (password.length >= 6) strength += 1; |
|||
if (/[A-Z]/.test(password)) strength += 1; |
|||
if (/[a-z]/.test(password)) strength += 1; |
|||
if (/[0-9]/.test(password)) strength += 1; |
|||
if (/[^a-zA-Z0-9]/.test(password)) strength += 1; |
|||
return strength; |
|||
}; |
|||
|
|||
const getPasswordStrengthColor = (strength: number): string => { |
|||
switch (strength) { |
|||
case 0: |
|||
case 1: |
|||
return 'bg-red-500'; |
|||
case 2: |
|||
case 3: |
|||
return 'bg-yellow-500'; |
|||
case 4: |
|||
case 5: |
|||
return 'bg-green-500'; |
|||
default: |
|||
return 'bg-gray-200'; |
|||
} |
|||
}; |
|||
|
|||
const getPasswordStrengthText = (strength: number): string => { |
|||
switch (strength) { |
|||
case 0: |
|||
case 1: |
|||
return '非常弱'; |
|||
case 2: |
|||
case 3: |
|||
return '中等'; |
|||
case 4: |
|||
case 5: |
|||
return '强'; |
|||
default: |
|||
return ''; |
|||
} |
|||
}; |
|||
|
|||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|||
const { name, value } = e.target; |
|||
setFormData(prev => ({ ...prev, [name]: value })); |
|||
// 清除对应字段的错误
|
|||
if (errors[name]) { |
|||
setErrors(prev => ({ ...prev, [name]: '' })); |
|||
} |
|||
}; |
|||
|
|||
const validateForm = (): boolean => { |
|||
try { |
|||
registerSchema.parse(formData); |
|||
setErrors({}); |
|||
return true; |
|||
} catch (error) { |
|||
if (error instanceof z.ZodError) { |
|||
const newErrors: Record<string, string> = {}; |
|||
error.errors.forEach((err) => { |
|||
if (err.path) { |
|||
newErrors[err.path[0]] = err.message; |
|||
} |
|||
}); |
|||
setErrors(newErrors); |
|||
} |
|||
return false; |
|||
} |
|||
}; |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault(); |
|||
setError(null); |
|||
|
|||
if (!validateForm()) { |
|||
return; |
|||
} |
|||
|
|||
setIsLoading(true); |
|||
try { |
|||
await onSubmit( |
|||
formData.username, |
|||
formData.email, |
|||
formData.password, |
|||
formData.phoneNumber || undefined |
|||
); |
|||
} catch (err) { |
|||
setError(err instanceof Error ? err.message : '注册失败'); |
|||
} finally { |
|||
setIsLoading(false); |
|||
} |
|||
}; |
|||
|
|||
const passwordStrength = calculatePasswordStrength(formData.password); |
|||
|
|||
return ( |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="space-y-4"> |
|||
<div> |
|||
<label htmlFor="username" className="block text-sm font-medium"> |
|||
用户名 |
|||
</label> |
|||
<input |
|||
id="username" |
|||
name="username" |
|||
type="text" |
|||
required |
|||
value={formData.username} |
|||
onChange={handleChange} |
|||
className={`mt-1 block w-full rounded-md border ${ |
|||
errors.username ? 'border-red-500' : 'border-input' |
|||
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
|
|||
placeholder="请输入用户名" |
|||
disabled={isLoading} |
|||
/> |
|||
{errors.username && ( |
|||
<p className="mt-1 text-sm text-red-500">{errors.username}</p> |
|||
)} |
|||
</div> |
|||
<div> |
|||
<label htmlFor="email" className="block text-sm font-medium"> |
|||
邮箱 |
|||
</label> |
|||
<input |
|||
id="email" |
|||
name="email" |
|||
type="email" |
|||
required |
|||
value={formData.email} |
|||
onChange={handleChange} |
|||
className={`mt-1 block w-full rounded-md border ${ |
|||
errors.email ? 'border-red-500' : 'border-input' |
|||
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
|
|||
placeholder="请输入邮箱" |
|||
disabled={isLoading} |
|||
/> |
|||
{errors.email && ( |
|||
<p className="mt-1 text-sm text-red-500">{errors.email}</p> |
|||
)} |
|||
</div> |
|||
<div> |
|||
<label htmlFor="phoneNumber" className="block text-sm font-medium"> |
|||
手机号码(选填) |
|||
</label> |
|||
<input |
|||
id="phoneNumber" |
|||
name="phoneNumber" |
|||
type="tel" |
|||
value={formData.phoneNumber} |
|||
onChange={handleChange} |
|||
className={`mt-1 block w-full rounded-md border ${ |
|||
errors.phoneNumber ? 'border-red-500' : 'border-input' |
|||
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
|
|||
placeholder="请输入手机号码" |
|||
disabled={isLoading} |
|||
/> |
|||
{errors.phoneNumber && ( |
|||
<p className="mt-1 text-sm text-red-500">{errors.phoneNumber}</p> |
|||
)} |
|||
</div> |
|||
<div> |
|||
<label htmlFor="password" className="block text-sm font-medium"> |
|||
密码 |
|||
</label> |
|||
<input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
required |
|||
value={formData.password} |
|||
onChange={handleChange} |
|||
className={`mt-1 block w-full rounded-md border ${ |
|||
errors.password ? 'border-red-500' : 'border-input' |
|||
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
|
|||
placeholder="请输入密码" |
|||
disabled={isLoading} |
|||
/> |
|||
{formData.password && ( |
|||
<div className="mt-2"> |
|||
<div className="h-1 w-full bg-gray-200 rounded-full overflow-hidden"> |
|||
<div |
|||
className={`h-full ${getPasswordStrengthColor(passwordStrength)}`} |
|||
style={{ width: `${(passwordStrength / 5) * 100}%` }} |
|||
/> |
|||
</div> |
|||
<p className="mt-1 text-xs text-gray-500"> |
|||
密码强度: {getPasswordStrengthText(passwordStrength)} |
|||
</p> |
|||
</div> |
|||
)} |
|||
{errors.password && ( |
|||
<p className="mt-1 text-sm text-red-500">{errors.password}</p> |
|||
)} |
|||
</div> |
|||
<div> |
|||
<label htmlFor="confirmPassword" className="block text-sm font-medium"> |
|||
确认密码 |
|||
</label> |
|||
<input |
|||
id="confirmPassword" |
|||
name="confirmPassword" |
|||
type="password" |
|||
required |
|||
value={formData.confirmPassword} |
|||
onChange={handleChange} |
|||
className={`mt-1 block w-full rounded-md border ${ |
|||
errors.confirmPassword ? 'border-red-500' : 'border-input' |
|||
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
|
|||
placeholder="请再次输入密码" |
|||
disabled={isLoading} |
|||
/> |
|||
{errors.confirmPassword && ( |
|||
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p> |
|||
)} |
|||
</div> |
|||
{error && ( |
|||
<div className="text-sm text-red-500"> |
|||
{error} |
|||
</div> |
|||
)} |
|||
</div> |
|||
<button |
|||
type="submit" |
|||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? '注册中...' : '注册'} |
|||
</button> |
|||
</form> |
|||
); |
|||
} |
@ -0,0 +1,126 @@ |
|||
import * as React from 'react'; |
|||
import * as ToastPrimitives from '@radix-ui/react-toast'; |
|||
import { cva, type VariantProps } from 'class-variance-authority'; |
|||
import { X } from 'lucide-react'; |
|||
import { cn } from '@/lib/utils'; |
|||
|
|||
const ToastProvider = ToastPrimitives.Provider; |
|||
|
|||
const ToastViewport = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Viewport>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Viewport |
|||
ref={ref} |
|||
className={cn( |
|||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; |
|||
|
|||
const toastVariants = cva( |
|||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: 'border bg-background text-foreground', |
|||
destructive: |
|||
'destructive group border-destructive bg-destructive text-destructive-foreground', |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: 'default', |
|||
}, |
|||
} |
|||
); |
|||
|
|||
const Toast = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Root>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & |
|||
VariantProps<typeof toastVariants> |
|||
>(({ className, variant, ...props }, ref) => { |
|||
return ( |
|||
<ToastPrimitives.Root |
|||
ref={ref} |
|||
className={cn(toastVariants({ variant }), className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
}); |
|||
Toast.displayName = ToastPrimitives.Root.displayName; |
|||
|
|||
const ToastAction = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Action>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Action |
|||
ref={ref} |
|||
className={cn( |
|||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
ToastAction.displayName = ToastPrimitives.Action.displayName; |
|||
|
|||
const ToastClose = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Close>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Close |
|||
ref={ref} |
|||
className={cn( |
|||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', |
|||
className |
|||
)} |
|||
toast-close="" |
|||
{...props} |
|||
> |
|||
<X className="h-4 w-4" /> |
|||
</ToastPrimitives.Close> |
|||
)); |
|||
ToastClose.displayName = ToastPrimitives.Close.displayName; |
|||
|
|||
const ToastTitle = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Title>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Title |
|||
ref={ref} |
|||
className={cn('text-sm font-semibold', className)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
ToastTitle.displayName = ToastPrimitives.Title.displayName; |
|||
|
|||
const ToastDescription = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Description>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Description |
|||
ref={ref} |
|||
className={cn('text-sm opacity-90', className)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
ToastDescription.displayName = ToastPrimitives.Description.displayName; |
|||
|
|||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; |
|||
|
|||
type ToastActionElement = React.ReactElement<typeof ToastAction>; |
|||
|
|||
export { |
|||
type ToastProps, |
|||
type ToastActionElement, |
|||
ToastProvider, |
|||
ToastViewport, |
|||
Toast, |
|||
ToastTitle, |
|||
ToastDescription, |
|||
ToastClose, |
|||
ToastAction, |
|||
}; |
@ -0,0 +1,33 @@ |
|||
import { |
|||
Toast, |
|||
ToastClose, |
|||
ToastDescription, |
|||
ToastProvider, |
|||
ToastTitle, |
|||
ToastViewport, |
|||
} from '@/components/ui/toast'; |
|||
import { useToast } from '@/components/ui/use-toast'; |
|||
|
|||
export function Toaster() { |
|||
const { toasts } = useToast(); |
|||
|
|||
return ( |
|||
<ToastProvider> |
|||
{toasts.map(function ({ id, title, description, action, ...props }) { |
|||
return ( |
|||
<Toast key={id} {...props}> |
|||
<div className="grid gap-1"> |
|||
{title && <ToastTitle>{title}</ToastTitle>} |
|||
{description && ( |
|||
<ToastDescription>{description}</ToastDescription> |
|||
)} |
|||
</div> |
|||
{action} |
|||
<ToastClose /> |
|||
</Toast> |
|||
); |
|||
})} |
|||
<ToastViewport /> |
|||
</ToastProvider> |
|||
); |
|||
} |
@ -0,0 +1,191 @@ |
|||
import * as React from 'react'; |
|||
|
|||
import type { |
|||
ToastActionElement, |
|||
ToastProps, |
|||
} from '@/components/ui/toast'; |
|||
|
|||
const TOAST_LIMIT = 1; |
|||
const TOAST_REMOVE_DELAY = 1000000; |
|||
|
|||
type ToasterToast = ToastProps & { |
|||
id: string; |
|||
title?: React.ReactNode; |
|||
description?: React.ReactNode; |
|||
action?: ToastActionElement; |
|||
}; |
|||
|
|||
const actionTypes = { |
|||
ADD_TOAST: 'ADD_TOAST', |
|||
UPDATE_TOAST: 'UPDATE_TOAST', |
|||
DISMISS_TOAST: 'DISMISS_TOAST', |
|||
REMOVE_TOAST: 'REMOVE_TOAST', |
|||
} as const; |
|||
|
|||
let count = 0; |
|||
|
|||
function genId() { |
|||
count = (count + 1) % Number.MAX_VALUE; |
|||
return count.toString(); |
|||
} |
|||
|
|||
type ActionType = typeof actionTypes; |
|||
|
|||
type Action = |
|||
| { |
|||
type: ActionType['ADD_TOAST']; |
|||
toast: ToasterToast; |
|||
} |
|||
| { |
|||
type: ActionType['UPDATE_TOAST']; |
|||
toast: Partial<ToasterToast>; |
|||
} |
|||
| { |
|||
type: ActionType['DISMISS_TOAST']; |
|||
toastId?: ToasterToast['id']; |
|||
} |
|||
| { |
|||
type: ActionType['REMOVE_TOAST']; |
|||
toastId?: ToasterToast['id']; |
|||
}; |
|||
|
|||
interface State { |
|||
toasts: ToasterToast[]; |
|||
} |
|||
|
|||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); |
|||
|
|||
const addToRemoveQueue = (toastId: string) => { |
|||
if (toastTimeouts.has(toastId)) { |
|||
return; |
|||
} |
|||
|
|||
const timeout = setTimeout(() => { |
|||
toastTimeouts.delete(toastId); |
|||
dispatch({ |
|||
type: 'REMOVE_TOAST', |
|||
toastId: toastId, |
|||
}); |
|||
}, TOAST_REMOVE_DELAY); |
|||
|
|||
toastTimeouts.set(toastId, timeout); |
|||
}; |
|||
|
|||
export const reducer = (state: State, action: Action): State => { |
|||
switch (action.type) { |
|||
case 'ADD_TOAST': |
|||
return { |
|||
...state, |
|||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), |
|||
}; |
|||
|
|||
case 'UPDATE_TOAST': |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === action.toast.id ? { ...t, ...action.toast } : t |
|||
), |
|||
}; |
|||
|
|||
case 'DISMISS_TOAST': { |
|||
const { toastId } = action; |
|||
|
|||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|||
// but I'll keep it here for simplicity
|
|||
if (toastId) { |
|||
addToRemoveQueue(toastId); |
|||
} else { |
|||
state.toasts.forEach((toast) => { |
|||
addToRemoveQueue(toast.id); |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === toastId || toastId === undefined |
|||
? { |
|||
...t, |
|||
open: false, |
|||
} |
|||
: t |
|||
), |
|||
}; |
|||
} |
|||
case 'REMOVE_TOAST': |
|||
if (action.toastId === undefined) { |
|||
return { |
|||
...state, |
|||
toasts: [], |
|||
}; |
|||
} |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.filter((t) => t.id !== action.toastId), |
|||
}; |
|||
} |
|||
}; |
|||
|
|||
const listeners: Array<(state: State) => void> = []; |
|||
|
|||
let memoryState: State = { toasts: [] }; |
|||
|
|||
function dispatch(action: Action) { |
|||
memoryState = reducer(memoryState, action); |
|||
listeners.forEach((listener) => { |
|||
listener(memoryState); |
|||
}); |
|||
} |
|||
|
|||
type Toast = Omit<ToasterToast, 'id'>; |
|||
|
|||
function toast({ ...props }: Toast) { |
|||
const id = genId(); |
|||
|
|||
const update = (props: ToasterToast) => |
|||
dispatch({ |
|||
type: 'UPDATE_TOAST', |
|||
toast: { ...props, id }, |
|||
}); |
|||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }); |
|||
|
|||
dispatch({ |
|||
type: 'ADD_TOAST', |
|||
toast: { |
|||
...props, |
|||
id, |
|||
open: true, |
|||
onOpenChange: (open) => { |
|||
if (!open) dismiss(); |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
return { |
|||
id: id, |
|||
dismiss, |
|||
update, |
|||
}; |
|||
} |
|||
|
|||
function useToast() { |
|||
const [state, setState] = React.useState<State>(memoryState); |
|||
|
|||
React.useEffect(() => { |
|||
listeners.push(setState); |
|||
return () => { |
|||
const index = listeners.indexOf(setState); |
|||
if (index > -1) { |
|||
listeners.splice(index, 1); |
|||
} |
|||
}; |
|||
}, [state]); |
|||
|
|||
return { |
|||
...state, |
|||
toast, |
|||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), |
|||
}; |
|||
} |
|||
|
|||
export { useToast, toast }; |
@ -0,0 +1,57 @@ |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { RegisterForm } from '@/components/auth/RegisterForm'; |
|||
import { useAuth } from '@/contexts/AuthContext'; |
|||
import { toast } from '@/components/ui/use-toast'; |
|||
|
|||
export function RegisterPage() { |
|||
const navigate = useNavigate(); |
|||
const { register, error } = useAuth(); |
|||
|
|||
const handleRegister = async (username: string, email: string, password: string, phoneNumber?: string) => { |
|||
try { |
|||
await register({ username, email, password, phoneNumber }); |
|||
toast({ |
|||
title: '注册成功', |
|||
description: '请使用您的账号登录', |
|||
duration: 3000, |
|||
}); |
|||
navigate('/login', { replace: true }); |
|||
} catch (error) { |
|||
console.error('注册失败:', error); |
|||
toast({ |
|||
title: '注册失败', |
|||
description: error instanceof Error ? error.message : '请稍后重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="flex min-h-screen items-center justify-center bg-background"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h2 className="text-2xl font-bold">注册账号</h2> |
|||
<p className="mt-2 text-sm text-muted-foreground"> |
|||
创建一个新账号以访问系统 |
|||
</p> |
|||
</div> |
|||
<RegisterForm onSubmit={handleRegister} /> |
|||
{typeof error === 'string' && error && ( |
|||
<div className="text-sm text-red-500 text-center"> |
|||
{error} |
|||
</div> |
|||
)} |
|||
<div className="text-center text-sm"> |
|||
<span className="text-muted-foreground">已有账号?</span> |
|||
<button |
|||
onClick={() => navigate('/login')} |
|||
className="ml-1 text-primary hover:underline" |
|||
> |
|||
立即登录 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
Loading…
Reference in new issue