Browse Source

feat: 添加用户注册功能 - 实现注册表单和验证 - 添加手机号码字段 - 优化表单验证规则 - 添加 Toast 通知组件

web
hyh 3 months ago
parent
commit
2a75722a4d
  1. 58
      src/CellularManagement.WebUI/package-lock.json
  2. 2
      src/CellularManagement.WebUI/package.json
  3. 2
      src/CellularManagement.WebUI/src/App.tsx
  4. 278
      src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx
  5. 126
      src/CellularManagement.WebUI/src/components/ui/toast.tsx
  6. 33
      src/CellularManagement.WebUI/src/components/ui/toaster.tsx
  7. 191
      src/CellularManagement.WebUI/src/components/ui/use-toast.ts
  8. 2
      src/CellularManagement.WebUI/src/constants/auth.ts
  9. 22
      src/CellularManagement.WebUI/src/contexts/AuthContext.tsx
  10. 9
      src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx
  11. 57
      src/CellularManagement.WebUI/src/pages/auth/RegisterPage.tsx
  12. 9
      src/CellularManagement.WebUI/src/routes/AppRouter.tsx
  13. 18
      src/CellularManagement.WebUI/src/services/apiService.ts
  14. 23
      src/CellularManagement.WebUI/src/services/authService.ts
  15. 7
      src/CellularManagement.WebUI/src/types/auth.ts

58
src/CellularManagement.WebUI/package-lock.json

@ -40,6 +40,7 @@
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.50.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.0",
@ -53,6 +54,7 @@
"@types/node": "^20.11.16",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-google-recaptcha": "^2.1.9",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
@ -2708,6 +2710,15 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-google-recaptcha": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.0",
"resolved": "https://registry.npmmirror.com/@types/semver/-/semver-7.7.0.tgz",
@ -4359,6 +4370,14 @@
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/human-signals": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-4.3.1.tgz",
@ -5397,6 +5416,16 @@
"node": ">= 6"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -5441,6 +5470,18 @@
"node": ">=0.10.0"
}
},
"node_modules/react-async-script": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
@ -5453,6 +5494,18 @@
"react": "^18.3.1"
}
},
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-hook-form": {
"version": "7.56.3",
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.56.3.tgz",
@ -5476,6 +5529,11 @@
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",

2
src/CellularManagement.WebUI/package.json

@ -42,6 +42,7 @@
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.50.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.0",
@ -55,6 +56,7 @@
"@types/node": "^20.11.16",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-google-recaptcha": "^2.1.9",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",

2
src/CellularManagement.WebUI/src/App.tsx

@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ThemeContextProvider } from './contexts/ThemeContext';
import { ThemeProvider } from './contexts/ThemeProvider';
import { SettingsContextProvider } from './contexts/SettingsContext';
import { Toaster } from './components/ui/toaster';
export function App() {
return (
@ -15,6 +16,7 @@ export function App() {
<ThemeProvider>
<SettingsContextProvider>
<AppRouter />
<Toaster />
</SettingsContextProvider>
</ThemeProvider>
</ThemeContextProvider>

278
src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx

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

126
src/CellularManagement.WebUI/src/components/ui/toast.tsx

@ -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,
};

33
src/CellularManagement.WebUI/src/components/ui/toaster.tsx

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

191
src/CellularManagement.WebUI/src/components/ui/use-toast.ts

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

2
src/CellularManagement.WebUI/src/constants/auth.ts

@ -28,6 +28,8 @@ export const AUTH_CONSTANTS = {
MESSAGES: {
LOGIN_SUCCESS: '登录成功',
LOGIN_FAILED: '登录失败,请检查用户名和密码',
REGISTER_SUCCESS: '注册成功',
REGISTER_FAILED: '注册失败,请稍后重试',
LOGOUT_SUCCESS: '已成功退出登录',
LOGOUT_FAILED: '退出登录失败',
TOKEN_REFRESHED: '令牌已刷新',

22
src/CellularManagement.WebUI/src/contexts/AuthContext.tsx

@ -1,5 +1,5 @@
import { createContext, useContext, useReducer, ReactNode, useMemo, useEffect, useState } from 'react';
import { AuthState, AuthContextType, LoginRequest, User } from '@/types/auth';
import { AuthState, AuthContextType, LoginRequest, User, RegisterRequest } from '@/types/auth';
import { useSetRecoilState, useRecoilState } from 'recoil';
import { userState } from '@/stores/userStore';
import { authService } from '@/services/authService';
@ -26,6 +26,9 @@ type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: { user: User; accessToken: string; refreshToken: string; rememberMe: boolean } }
| { type: 'LOGIN_FAILURE'; payload: { error: string } }
| { type: 'REGISTER_START' }
| { type: 'REGISTER_SUCCESS' }
| { type: 'REGISTER_FAILURE'; payload: { error: string } }
| { type: 'LOGOUT' }
| { type: 'CLEAR_ERROR' }
| { type: 'SET_USER'; payload: { user: User; accessToken: string; refreshToken: string } }
@ -66,6 +69,20 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
userPermissions: [],
error: action.payload.error,
};
case 'REGISTER_START':
return { ...state, isLoading: true, error: null };
case 'REGISTER_SUCCESS':
return {
...state,
isLoading: false,
error: null,
};
case 'REGISTER_FAILURE':
return {
...state,
isLoading: false,
error: action.payload.error,
};
case 'LOGOUT':
return initialState;
case 'CLEAR_ERROR':
@ -139,6 +156,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login: async (request: LoginRequest) => {
await authService.handleLogin(request, dispatch);
},
register: async (request: RegisterRequest) => {
await authService.handleRegister(request, dispatch);
},
logout: async () => {
await authService.handleLogout(dispatch);
},

9
src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx

@ -29,6 +29,15 @@ export function LoginPage() {
{error}
</div>
)}
<div className="text-center text-sm">
<span className="text-muted-foreground"></span>
<button
onClick={() => navigate('/register')}
className="ml-1 text-primary hover:underline"
>
</button>
</div>
</div>
</div>
);

57
src/CellularManagement.WebUI/src/pages/auth/RegisterPage.tsx

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

9
src/CellularManagement.WebUI/src/routes/AppRouter.tsx

@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
// 使用 lazy 加载组件
const LoginPage = lazy(() => import('@/pages/auth/LoginPage').then(module => ({ default: module.LoginPage })));
const RegisterPage = lazy(() => import('@/pages/auth/RegisterPage').then(module => ({ default: module.RegisterPage })));
const DashboardHome = lazy(() => import('@/pages/dashboard/DashboardHome').then(module => ({ default: module.DashboardHome })));
const ForbiddenPage = lazy(() => import('@/pages/auth/ForbiddenPage'));
const UserManagePage = lazy(() => import('@/pages/dashboard/UserManagePage'));
@ -29,6 +30,14 @@ export function AppRouter() {
</Suspense>
}
/>
<Route
path="/register"
element={
<Suspense fallback={<LoadingFallback />}>
<RegisterPage />
</Suspense>
}
/>
<Route path="/403" element={
<Suspense fallback={<LoadingFallback />}>
<ForbiddenPage />

18
src/CellularManagement.WebUI/src/services/apiService.ts

@ -1,9 +1,10 @@
import { LoginRequest, LoginResponse, User, OperationResult } from '@/types/auth';
import { LoginRequest, RegisterRequest, LoginResponse, User, OperationResult } from '@/types/auth';
import { httpClient } from '@/lib/http-client';
import { AUTH_CONSTANTS } from '@/constants/auth';
export interface ApiService {
login: (request: LoginRequest) => Promise<OperationResult<LoginResponse>>;
register: (request: RegisterRequest) => Promise<OperationResult<void>>;
refreshToken: (refreshToken: string) => Promise<OperationResult<LoginResponse>>;
logout: () => Promise<OperationResult<void>>;
getCurrentUser: () => Promise<OperationResult<User>>;
@ -33,6 +34,21 @@ export const apiService: ApiService = {
}
},
register: async (request: RegisterRequest): Promise<OperationResult<void>> => {
try {
await httpClient.post('/Auth/Register', request);
return {
success: true,
message: AUTH_CONSTANTS.MESSAGES.REGISTER_SUCCESS
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.REGISTER_FAILED
};
}
},
refreshToken: async (refreshToken: string): Promise<OperationResult<LoginResponse>> => {
try {
const response = await httpClient.post<LoginResponse>('/Auth/RefreshToken', { refreshToken });

23
src/CellularManagement.WebUI/src/services/authService.ts

@ -1,4 +1,4 @@
import { LoginRequest, User, OperationResult, AuthAction } from '@/types/auth';
import { LoginRequest, RegisterRequest, User, OperationResult, AuthAction } from '@/types/auth';
import { storageService } from './storageService';
import { apiService } from '@/services/apiService';
import { AUTH_CONSTANTS } from '@/constants/auth';
@ -8,6 +8,7 @@ export interface AuthService {
checkLoginAttempts: () => boolean;
updateLoginAttempts: (success: boolean) => void;
handleLogin: (request: LoginRequest, dispatch: React.Dispatch<AuthAction>) => Promise<void>;
handleRegister: (request: RegisterRequest, dispatch: React.Dispatch<AuthAction>) => Promise<void>;
handleLogout: (dispatch: React.Dispatch<AuthAction>) => Promise<void>;
handleRefreshToken: (dispatch: React.Dispatch<AuthAction>) => Promise<void>;
initializeAuth: (dispatch: React.Dispatch<AuthAction>) => Promise<void>;
@ -83,6 +84,26 @@ export const authService: AuthService = {
}
},
handleRegister: async (request: RegisterRequest, dispatch: React.Dispatch<AuthAction>) => {
try {
console.log('[authService] handleRegister start', request);
dispatch({ type: 'REGISTER_START' });
const result = await apiService.register(request);
console.log('[authService] register result', result);
if (!result.success) {
throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.REGISTER_FAILED);
}
dispatch({ type: 'REGISTER_SUCCESS' });
} catch (error: any) {
console.error('[authService] handleRegister error', error);
dispatch({
type: 'REGISTER_FAILURE',
payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR }
});
throw error;
}
},
handleLogout: async (dispatch: React.Dispatch<AuthAction>) => {
try {
console.log('[authService] handleLogout start');

7
src/CellularManagement.WebUI/src/types/auth.ts

@ -37,8 +37,15 @@ export type AuthAction =
| { type: 'SET_USER'; payload: { user: User; accessToken: string; refreshToken: string } }
| { type: 'SET_REMEMBER_ME'; payload: boolean };
export interface RegisterRequest {
username: string;
email: string;
password: string;
}
export interface AuthContextType extends AuthState {
login: (request: LoginRequest) => Promise<void>;
register: (request: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
}

Loading…
Cancel
Save