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