Browse Source

重构前端代码结构,优化路由和页面组织

web
hyh 3 months ago
parent
commit
71953e6277
  1. 2
      src/CellularManagement.WebUI/src/App.tsx
  2. 28
      src/CellularManagement.WebUI/src/components/protected-route.tsx
  3. 26
      src/CellularManagement.WebUI/src/constants/routes.ts
  4. 55
      src/CellularManagement.WebUI/src/index.css
  5. 27
      src/CellularManagement.WebUI/src/lib/api.ts
  6. 23
      src/CellularManagement.WebUI/src/lib/error-handler.ts
  7. 22
      src/CellularManagement.WebUI/src/pages/auth/forgot-password.tsx
  8. 7
      src/CellularManagement.WebUI/src/pages/auth/login.tsx
  9. 48
      src/CellularManagement.WebUI/src/pages/auth/register.tsx
  10. 37
      src/CellularManagement.WebUI/src/pages/auth/reset-password.tsx
  11. 0
      src/CellularManagement.WebUI/src/pages/dashboard/home.tsx
  12. 166
      src/CellularManagement.WebUI/src/pages/user/profile.tsx
  13. 107
      src/CellularManagement.WebUI/src/pages/user/settings.tsx
  14. 48
      src/CellularManagement.WebUI/src/router.tsx
  15. 29
      src/CellularManagement.WebUI/src/routes/index.tsx
  16. 46
      src/CellularManagement.WebUI/src/services/auth.service.ts
  17. 44
      src/CellularManagement.WebUI/src/services/settings.service.ts
  18. 57
      src/CellularManagement.WebUI/src/services/user.service.ts
  19. 14
      src/CellularManagement.WebUI/src/store/layoutSetting.ts
  20. 27
      src/CellularManagement.WebUI/src/types/auth.ts

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

@ -5,7 +5,7 @@ import { router } from '@/routes'
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<RouterProvider router={router} />
<Toaster />
</ThemeProvider>

28
src/CellularManagement.WebUI/src/components/protected-route.tsx

@ -1,35 +1,17 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usePermissions } from '@/hooks/use-permissions'
import { Permission } from '@/types/auth'
import { ROUTES } from '@/constants/routes'
interface ProtectedRouteProps {
children: React.ReactNode
requiredPermissions?: Permission[]
requireAllPermissions?: boolean
}
export function ProtectedRoute({
children,
requiredPermissions = [],
requireAllPermissions = false,
}: ProtectedRouteProps) {
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useAuthStore()
const location = useLocation()
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const { hasAnyPermission, hasAllPermissions } = usePermissions()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (requiredPermissions.length > 0) {
const hasRequiredPermissions = requireAllPermissions
? hasAllPermissions(requiredPermissions)
: hasAnyPermission(requiredPermissions)
if (!hasRequiredPermissions) {
return <Navigate to="/" replace />
}
if (!user) {
return <Navigate to={ROUTES.AUTH.LOGIN} state={{ from: location }} replace />
}
return <>{children}</>

26
src/CellularManagement.WebUI/src/constants/routes.ts

@ -0,0 +1,26 @@
export const ROUTES = {
// 认证相关路由
AUTH: {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
FORGOT_PASSWORD: '/auth/forgot-password',
RESET_PASSWORD: '/auth/reset-password',
},
// 用户相关路由
USER: {
PROFILE: '/user/profile',
SETTINGS: '/user/settings',
},
// 仪表板相关路由
DASHBOARD: {
HOME: '/dashboard',
},
// 根路由
ROOT: '/',
} as const
// 类型定义
export type RouteKey = keyof typeof ROUTES
export type AuthRouteKey = keyof typeof ROUTES.AUTH
export type UserRouteKey = keyof typeof ROUTES.USER
export type DashboardRouteKey = keyof typeof ROUTES.DASHBOARD

55
src/CellularManagement.WebUI/src/index.css

@ -5,13 +5,13 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
@ -74,3 +74,52 @@
@apply bg-background text-foreground;
}
}
@layer components {
.gradient-primary {
@apply bg-gradient-to-r;
background-image: linear-gradient(90deg, #F7BFCB 0%, #BEE7E8 100%);
}
.gradient-secondary {
@apply bg-gradient-to-r;
background-image: linear-gradient(90deg, #BEE7E8 0%, #D6F8B8 100%);
}
.gradient-accent {
@apply bg-gradient-to-r;
background-image: linear-gradient(90deg, #D6F8B8 0%, #F7E2C7 100%);
}
.gradient-card {
@apply bg-gradient-to-br;
background-image: linear-gradient(135deg, #F7E2C7 0%, #F7BFCB 100%);
}
.gradient-button {
@apply relative overflow-hidden transition-all duration-300 hover:shadow-lg;
}
.gradient-button::before {
content: '';
@apply absolute inset-0 bg-gradient-to-r from-blue-600 to-indigo-600 opacity-0 transition-opacity duration-300;
}
.gradient-button:hover::before {
@apply opacity-100;
}
.gradient-text {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(90deg, #F7BFCB 0%, #BEE7E8 33%, #D6F8B8 66%, #F7E2C7 100%);
}
.gradient-border {
@apply relative;
}
.gradient-border::after {
content: '';
@apply absolute inset-0 rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 -z-10;
}
}

27
src/CellularManagement.WebUI/src/lib/api.ts

@ -0,0 +1,27 @@
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://localhost:7268/api',
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/auth/login'
}
return Promise.reject(error)
}
)

23
src/CellularManagement.WebUI/src/lib/error-handler.ts

@ -1,17 +1,20 @@
import { toast } from 'sonner'
import { AxiosError } from 'axios'
import { toast } from 'react-hot-toast'
import { ApiResponse } from '@/types/api'
export const handleApiError = (error: unknown) => {
if (error instanceof AxiosError) {
const response = error.response?.data as ApiResponse<unknown>
if (response?.errorMessages?.length) {
response.errorMessages.forEach((message) => {
toast.error(message)
})
export function handleApiError(error: unknown) {
if (error instanceof Error) {
if ('response' in error) {
const axiosError = error as AxiosError<{ errorMessages: string[] }>
const errorMessages = axiosError.response?.data?.errorMessages
if (errorMessages?.length) {
errorMessages.forEach(message => toast.error(message))
} else {
toast.error('发生未知错误,请稍后重试')
}
} else {
toast.error('发生错误,请稍后重试')
toast.error(error.message)
}
} else {
toast.error('发生未知错误,请稍后重试')

22
src/CellularManagement.WebUI/src/pages/forgot-password.tsx → src/CellularManagement.WebUI/src/pages/auth/forgot-password.tsx

@ -1,14 +1,17 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Link } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { authService } from '@/services/auth.service'
import { handleApiError } from '@/lib/error-handler'
import { ROUTES } from '@/constants/routes'
export function ForgotPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const forgotPasswordSchema = z.object({
email: z.string().email(t('validation.email')),
@ -26,10 +29,11 @@ export function ForgotPassword() {
const onSubmit = async (data: ForgotPasswordFormData) => {
try {
await authService.requestPasswordReset(data)
toast.success(t('auth.requestResetSuccess'))
await authService.forgotPassword(data)
toast.success(t('auth.forgotPasswordSuccess'))
navigate(ROUTES.AUTH.LOGIN)
} catch (error) {
toast.error(t('auth.requestResetError'))
handleApiError(error)
}
}
@ -37,9 +41,9 @@ export function ForgotPassword() {
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold">{t('auth.requestPasswordReset')}</h1>
<h1 className="text-2xl font-bold">{t('auth.forgotPassword')}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t('auth.enterEmail')}
{t('auth.forgotPasswordDescription')}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@ -54,6 +58,7 @@ export function ForgotPassword() {
id="email"
type="email"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('auth.emailPlaceholder')}
{...register('email')}
/>
{errors.email && (
@ -65,11 +70,12 @@ export function ForgotPassword() {
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? t('common.loading') : t('auth.requestPasswordReset')}
{isSubmitting ? t('common.loading') : t('auth.sendResetLink')}
</Button>
<div className="text-center text-sm">
<span className="text-muted-foreground">{t('auth.rememberPassword')}</span>{' '}
<Link
to="/login"
to={ROUTES.AUTH.LOGIN}
className="text-primary hover:underline"
>
{t('auth.login')}

7
src/CellularManagement.WebUI/src/pages/login.tsx → src/CellularManagement.WebUI/src/pages/auth/login.tsx

@ -9,13 +9,14 @@ import { Checkbox } from '@/components/ui/checkbox'
import { useAuthStore } from '@/store/auth'
import { authService } from '@/services/auth.service'
import { handleApiError } from '@/lib/error-handler'
import { ROUTES } from '@/constants/routes'
export function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const login = useAuthStore((state) => state.login)
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || ROUTES.DASHBOARD.HOME
const loginSchema = z.object({
userNameOrEmail: z.string()
@ -97,7 +98,7 @@ export function Login() {
{t('auth.password')}
</label>
<Link
to="/forgot-password"
to={ROUTES.AUTH.FORGOT_PASSWORD}
className="text-sm text-primary hover:underline"
>
{t('auth.forgotPassword')}
@ -136,7 +137,7 @@ export function Login() {
<div className="text-center text-sm">
<span className="text-muted-foreground">{t('auth.noAccount')}</span>{' '}
<Link
to="/register"
to={ROUTES.AUTH.REGISTER}
className="text-primary hover:underline"
>
{t('auth.register')}

48
src/CellularManagement.WebUI/src/pages/register.tsx → src/CellularManagement.WebUI/src/pages/auth/register.tsx

@ -6,14 +6,21 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { authService } from '@/services/auth.service'
import { ROUTES } from '@/constants/routes'
export function Register() {
const { t } = useTranslation()
const navigate = useNavigate()
const registerSchema = z.object({
userName: z.string()
.min(3, t('validation.userNameMinLength'))
.max(20, t('validation.userNameMaxLength'))
.regex(/^[a-zA-Z0-9_]+$/, t('validation.userNameFormat')),
name: z.string().min(2, t('validation.displayNameMinLength')),
email: z.string().email(t('validation.email')),
phoneNumber: z.string()
.regex(/^1[3-9]\d{9}$/, t('validation.phoneNumber')),
password: z.string().min(6, t('validation.passwordMinLength')),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
@ -33,9 +40,10 @@ export function Register() {
const onSubmit = async (data: RegisterFormData) => {
try {
await authService.register(data)
const { confirmPassword, ...registerData } = data
await authService.register(registerData)
toast.success(t('auth.registerSuccess'))
navigate('/login')
navigate(ROUTES.AUTH.LOGIN)
} catch (error) {
toast.error(t('auth.registerError'))
}
@ -51,6 +59,23 @@ export function Register() {
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<label
htmlFor="userName"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('auth.userName')}
</label>
<input
id="userName"
type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register('userName')}
/>
{errors.userName && (
<p className="text-sm text-destructive">{errors.userName.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="name"
@ -85,6 +110,23 @@ export function Register() {
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="phoneNumber"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('auth.phoneNumber')}
</label>
<input
id="phoneNumber"
type="tel"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register('phoneNumber')}
/>
{errors.phoneNumber && (
<p className="text-sm text-destructive">{errors.phoneNumber.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="password"
@ -129,7 +171,7 @@ export function Register() {
<div className="text-center text-sm">
<span className="text-muted-foreground">{t('auth.hasAccount')}</span>{' '}
<Link
to="/login"
to={ROUTES.AUTH.LOGIN}
className="text-primary hover:underline"
>
{t('auth.login')}

37
src/CellularManagement.WebUI/src/pages/reset-password.tsx → src/CellularManagement.WebUI/src/pages/auth/reset-password.tsx

@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { authService } from '@/services/auth.service'
import { handleApiError } from '@/lib/error-handler'
import { ROUTES } from '@/constants/routes'
export function ResetPassword() {
const { t } = useTranslation()
@ -33,44 +35,27 @@ export function ResetPassword() {
const onSubmit = async (data: ResetPasswordFormData) => {
if (!token) {
toast.error(t('auth.invalidToken'))
toast.error(t('auth.invalidResetToken'))
navigate(ROUTES.AUTH.FORGOT_PASSWORD)
return
}
try {
await authService.resetPassword({
token,
password: data.password,
confirmPassword: data.confirmPassword,
})
await authService.resetPassword({ ...data, token })
toast.success(t('auth.resetPasswordSuccess'))
navigate('/login')
navigate(ROUTES.AUTH.LOGIN)
} catch (error) {
toast.error(t('auth.resetPasswordError'))
handleApiError(error)
}
}
if (!token) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold text-destructive">
{t('auth.invalidToken')}
</h1>
</div>
</div>
</div>
)
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold">{t('auth.resetPassword')}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t('auth.enterNewPassword')}
{t('auth.resetPasswordDescription')}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@ -79,12 +64,13 @@ export function ResetPassword() {
htmlFor="password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('auth.password')}
{t('auth.newPassword')}
</label>
<input
id="password"
type="password"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('auth.newPasswordPlaceholder')}
{...register('password')}
/>
{errors.password && (
@ -96,12 +82,13 @@ export function ResetPassword() {
htmlFor="confirmPassword"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('auth.confirmPassword')}
{t('auth.confirmNewPassword')}
</label>
<input
id="confirmPassword"
type="password"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('auth.confirmNewPasswordPlaceholder')}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (

0
src/CellularManagement.WebUI/src/pages/home.tsx → src/CellularManagement.WebUI/src/pages/dashboard/home.tsx

166
src/CellularManagement.WebUI/src/pages/profile.tsx → src/CellularManagement.WebUI/src/pages/user/profile.tsx

@ -6,6 +6,8 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { userService } from '@/services/user.service'
import { useAuthStore } from '@/store/auth'
import { useTranslation } from 'react-i18next'
import { handleApiError } from '@/lib/error-handler'
const profileSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@ -26,7 +28,8 @@ type ProfileFormData = z.infer<typeof profileSchema>
type PasswordFormData = z.infer<typeof passwordSchema>
export function Profile() {
const { user, login } = useAuthStore()
const { t } = useTranslation()
const { user, login, updateUser } = useAuthStore()
const [isLoading, setIsLoading] = useState(true)
const [avatar, setAvatar] = useState<string>()
@ -36,6 +39,11 @@ export function Profile() {
formState: { errors: profileErrors, isSubmitting: isProfileSubmitting },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: user?.name,
phone: user?.phone,
address: user?.address,
},
})
const {
@ -71,10 +79,10 @@ export function Profile() {
const handleProfileUpdate = async (data: ProfileFormData) => {
try {
const updatedProfile = await userService.updateProfile(data)
login(updatedProfile, useAuthStore.getState().token!)
toast.success('个人资料更新成功')
updateUser(updatedProfile)
toast.success(t('user.profileUpdateSuccess'))
} catch (error) {
toast.error('更新个人资料失败')
handleApiError(error)
}
}
@ -110,88 +118,88 @@ export function Profile() {
}
return (
<div className="container max-w-2xl py-8">
<h1 className="mb-8 text-2xl font-bold"></h1>
<div className="container mx-auto max-w-2xl py-8">
<div className="space-y-8">
{/* 头像上传 */}
<div className="flex items-center space-x-4">
<div className="relative h-24 w-24 overflow-hidden rounded-full">
<img
src={avatar || `https://ui-avatars.com/api/?name=${user?.name}`}
alt="头像"
className="h-full w-full object-cover"
<div>
<h1 className="text-2xl font-bold">{t('user.profile')}</h1>
<p className="text-muted-foreground">
{t('user.profileDescription')}
</p>
</div>
<form onSubmit={handleProfileSubmit(handleProfileUpdate)} className="space-y-6">
<div className="space-y-2">
<label
htmlFor="name"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.name')}
</label>
<input
id="name"
type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('name')}
/>
{profileErrors.name && (
<p className="text-sm text-destructive">{profileErrors.name.message}</p>
)}
</div>
<div>
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.email')}
</label>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
id="avatar-upload"
id="email"
type="email"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('email')}
/>
<label htmlFor="avatar-upload">
<Button variant="outline" asChild>
<span></span>
</Button>
{profileErrors.email && (
<p className="text-sm text-destructive">{profileErrors.email.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="phone"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.phone')}
</label>
<input
id="phone"
type="tel"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('phone')}
/>
{profileErrors.phone && (
<p className="text-sm text-destructive">{profileErrors.phone.message}</p>
)}
</div>
</div>
{/* 基本信息表单 */}
<form onSubmit={handleProfileSubmit(handleProfileUpdate)} className="space-y-6">
<h2 className="text-xl font-semibold"></h2>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('name')}
/>
{profileErrors.name && (
<p className="text-sm text-destructive">{profileErrors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="email"
value={user?.email}
disabled
className="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="tel"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('phone')}
/>
{profileErrors.phone && (
<p className="text-sm text-destructive">{profileErrors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('address')}
/>
{profileErrors.address && (
<p className="text-sm text-destructive">{profileErrors.address.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="address"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.address')}
</label>
<input
id="address"
type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...registerProfile('address')}
/>
{profileErrors.address && (
<p className="text-sm text-destructive">{profileErrors.address.message}</p>
)}
</div>
<Button type="submit" disabled={isProfileSubmitting}>
{isProfileSubmitting ? '保存中...' : '保存修改'}
<Button
type="submit"
disabled={isProfileSubmitting}
>
{isProfileSubmitting ? t('common.loading') : t('user.saveChanges')}
</Button>
</form>
@ -243,7 +251,7 @@ export function Profile() {
</div>
<Button type="submit" disabled={isPasswordSubmitting}>
{isPasswordSubmitting ? '修改中...' : '修改密码'}
{isPasswordSubmitting ? t('common.loading') : t('user.changePassword')}
</Button>
</form>
</div>

107
src/CellularManagement.WebUI/src/pages/settings.tsx → src/CellularManagement.WebUI/src/pages/user/settings.tsx

@ -11,6 +11,10 @@ import { Input } from '@/components/ui/input'
import { settingsService, UserSettings } from '@/services/settings.service'
import { useTheme } from '@/hooks/use-theme'
import { languages } from '@/i18n'
import { useAuthStore } from '@/store/auth'
import { userService } from '@/services/user.service'
import { handleApiError } from '@/lib/error-handler'
import { ROUTES } from '@/constants/routes'
const timezones = [
{ value: 'Asia/Shanghai', label: '中国标准时间 (UTC+8)' },
@ -22,6 +26,7 @@ export function Settings() {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const { setTheme } = useTheme()
const { user } = useAuthStore()
const settingsSchema = z.object({
theme: z.enum(['light', 'dark', 'system']),
@ -45,6 +50,26 @@ export function Settings() {
resolver: zodResolver(settingsSchema),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(6, t('validation.passwordMinLength')),
newPassword: z.string().min(6, t('validation.passwordMinLength')),
confirmPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: t('validation.passwordMismatch'),
path: ['confirmPassword'],
})
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>
const {
register: changePasswordRegister,
handleSubmit: changePasswordHandleSubmit,
formState: { errors: changePasswordErrors, isSubmitting: changePasswordIsSubmitting },
reset: changePasswordReset,
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
})
useEffect(() => {
loadSettings()
}, [])
@ -70,6 +95,16 @@ export function Settings() {
}
}
const changePasswordSubmit = async (data: ChangePasswordFormData) => {
try {
await userService.changePassword(data)
toast.success(t('user.passwordChangeSuccess'))
changePasswordReset()
} catch (error) {
handleApiError(error)
}
}
if (isLoading) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
@ -82,16 +117,15 @@ export function Settings() {
}
return (
<div className="container max-w-2xl py-8">
<div className="space-y-6">
<div className="container mx-auto max-w-2xl py-8">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">{t('settings.title')}</h1>
<p className="text-sm text-muted-foreground">
{t('settings.description')}
<h1 className="text-2xl font-bold">{t('user.settings')}</h1>
<p className="text-muted-foreground">
{t('user.settingsDescription')}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<h2 className="text-lg font-medium">{t('settings.appearance')}</h2>
<div className="space-y-2">
@ -180,6 +214,65 @@ export function Settings() {
</Button>
</div>
</form>
<form onSubmit={changePasswordHandleSubmit(changePasswordSubmit)} className="space-y-6">
<div className="space-y-2">
<label
htmlFor="currentPassword"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.currentPassword')}
</label>
<input
id="currentPassword"
type="password"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...changePasswordRegister('currentPassword')}
/>
{changePasswordErrors.currentPassword && (
<p className="text-sm text-destructive">{changePasswordErrors.currentPassword.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="newPassword"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.newPassword')}
</label>
<input
id="newPassword"
type="password"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...changePasswordRegister('newPassword')}
/>
{changePasswordErrors.newPassword && (
<p className="text-sm text-destructive">{changePasswordErrors.newPassword.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('user.confirmNewPassword')}
</label>
<input
id="confirmPassword"
type="password"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...changePasswordRegister('confirmPassword')}
/>
{changePasswordErrors.confirmPassword && (
<p className="text-sm text-destructive">{changePasswordErrors.confirmPassword.message}</p>
)}
</div>
<Button
type="submit"
disabled={changePasswordIsSubmitting}
>
{changePasswordIsSubmitting ? t('common.loading') : t('user.changePassword')}
</Button>
</form>
</div>
</div>
)

48
src/CellularManagement.WebUI/src/router.tsx

@ -0,0 +1,48 @@
import { createBrowserRouter } from 'react-router-dom'
import { Layout } from '@/components/layout'
import { Login } from '@/pages/auth/login'
import { Register } from '@/pages/auth/register'
import { ForgotPassword } from '@/pages/auth/forgot-password'
import { ResetPassword } from '@/pages/auth/reset-password'
import { Profile } from '@/pages/user/profile'
import { Settings } from '@/pages/user/settings'
import { Home } from '@/pages/dashboard/home'
import { ROUTES } from '@/constants/routes'
import { ProtectedRoute } from '@/components/protected-route'
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
index: true,
element: <ProtectedRoute><Home /></ProtectedRoute>,
},
{
path: ROUTES.AUTH.LOGIN,
element: <Login />,
},
{
path: ROUTES.AUTH.REGISTER,
element: <Register />,
},
{
path: ROUTES.AUTH.FORGOT_PASSWORD,
element: <ForgotPassword />,
},
{
path: ROUTES.AUTH.RESET_PASSWORD,
element: <ResetPassword />,
},
{
path: ROUTES.USER.PROFILE,
element: <ProtectedRoute><Profile /></ProtectedRoute>,
},
{
path: ROUTES.USER.SETTINGS,
element: <ProtectedRoute><Settings /></ProtectedRoute>,
},
],
},
])

29
src/CellularManagement.WebUI/src/routes/index.tsx

@ -1,13 +1,14 @@
import { createBrowserRouter } from 'react-router-dom'
import { Layout } from '@/components/layout'
import { Login } from '@/pages/login'
import { Register } from '@/pages/register'
import { ForgotPassword } from '@/pages/forgot-password'
import { ResetPassword } from '@/pages/reset-password'
import { Home } from '@/pages/home'
import { Profile } from '@/pages/profile'
import { Settings } from '@/pages/settings'
import { Login } from '@/pages/auth/login'
import { Register } from '@/pages/auth/register'
import { ForgotPassword } from '@/pages/auth/forgot-password'
import { ResetPassword } from '@/pages/auth/reset-password'
import { Home } from '@/pages/dashboard/home'
import { Profile } from '@/pages/user/profile'
import { Settings } from '@/pages/user/settings'
import { ProtectedRoute } from '@/components/protected-route'
import { ROUTES } from '@/constants/routes'
export const router = createBrowserRouter([
{
@ -23,23 +24,23 @@ export const router = createBrowserRouter([
),
},
{
path: 'login',
path: ROUTES.AUTH.LOGIN,
element: <Login />,
},
{
path: 'register',
path: ROUTES.AUTH.REGISTER,
element: <Register />,
},
{
path: 'forgot-password',
path: ROUTES.AUTH.FORGOT_PASSWORD,
element: <ForgotPassword />,
},
{
path: 'reset-password',
path: ROUTES.AUTH.RESET_PASSWORD,
element: <ResetPassword />,
},
{
path: 'profile',
path: ROUTES.USER.PROFILE,
element: (
<ProtectedRoute>
<Profile />
@ -47,9 +48,9 @@ export const router = createBrowserRouter([
),
},
{
path: 'settings',
path: ROUTES.USER.SETTINGS,
element: (
<ProtectedRoute requiredPermissions={['settings.view']}>
<ProtectedRoute>
<Settings />
</ProtectedRoute>
),

46
src/CellularManagement.WebUI/src/services/auth.service.ts

@ -1,45 +1,35 @@
import { apiClient } from '@/lib/api-client'
import { AuthResponse, LoginRequest, RegisterRequest, ResetPasswordRequest, ResetPasswordConfirmRequest } from '@/types/auth'
import { api } from '@/lib/api'
import type {
LoginRequest,
RegisterRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
User,
} from '@/types/auth'
class AuthService {
async login(data: LoginRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', data)
async login(data: LoginRequest) {
const response = await api.post<{ user: User; token: string; successMessage: string }>('/auth/login', data)
return response.data
}
async register(data: RegisterRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/register', data)
async register(data: RegisterRequest) {
const response = await api.post<{ user: User; successMessage: string }>('/auth/register', data)
return response.data
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/refresh-token', { refreshToken })
async forgotPassword(data: ForgotPasswordRequest) {
const response = await api.post<{ successMessage: string }>('/auth/forgot-password', data)
return response.data
}
async requestPasswordReset(data: ResetPasswordRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/forgot-password', data)
async resetPassword(data: ResetPasswordRequest) {
const response = await api.post<{ successMessage: string }>('/auth/reset-password', data)
return response.data
}
async resetPassword(data: ResetPasswordConfirmRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/reset-password', data)
return response.data
}
async getCurrentUser(): Promise<AuthResponse> {
const response = await apiClient.get<AuthResponse>('/auth/me')
return response.data
}
async updateUserRole(userId: string, role: string): Promise<AuthResponse> {
const response = await apiClient.patch<AuthResponse>(`/auth/users/${userId}/role`, { role })
return response.data
}
async updateUserPermissions(userId: string, permissions: string[]): Promise<AuthResponse> {
const response = await apiClient.patch<AuthResponse>(`/auth/users/${userId}/permissions`, { permissions })
return response.data
async logout() {
await api.post('/auth/logout')
}
}

44
src/CellularManagement.WebUI/src/services/settings.service.ts

@ -1,4 +1,4 @@
import { apiClient } from '@/lib/api-client'
import { api } from '@/lib/api'
export interface UserSettings {
theme: 'light' | 'dark' | 'system'
@ -11,39 +11,41 @@ export interface UserSettings {
timezone: string
}
export const settingsService = {
async getSettings(): Promise<UserSettings> {
const response = await apiClient.get<UserSettings>('/settings')
return response.data
},
class SettingsService {
async getSettings() {
const response = await api.get<{ settings: UserSettings }>('/user/settings')
return response.data.settings
}
async updateSettings(settings: Partial<UserSettings>): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings', settings)
return response.data
},
async updateSettings(data: Partial<UserSettings>) {
const response = await api.patch<{ settings: UserSettings }>('/user/settings', data)
return response.data.settings
}
async updateTheme(theme: UserSettings['theme']): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings/theme', { theme })
const response = await api.patch<UserSettings>('/settings/theme', { theme })
return response.data
},
}
async updateLanguage(language: string): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings/language', { language })
const response = await api.patch<UserSettings>('/settings/language', { language })
return response.data
},
}
async updateNotifications(notifications: UserSettings['notifications']): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings/notifications', { notifications })
const response = await api.patch<UserSettings>('/settings/notifications', { notifications })
return response.data
},
}
async updateDisplayName(displayName: string): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings/display-name', { displayName })
const response = await api.patch<UserSettings>('/settings/display-name', { displayName })
return response.data
},
}
async updateTimezone(timezone: string): Promise<UserSettings> {
const response = await apiClient.patch<UserSettings>('/settings/timezone', { timezone })
const response = await api.patch<UserSettings>('/settings/timezone', { timezone })
return response.data
},
}
}
}
export const settingsService = new SettingsService()

57
src/CellularManagement.WebUI/src/services/user.service.ts

@ -1,49 +1,32 @@
import { apiClient } from '@/lib/api-client'
import { api } from '@/lib/api'
import type { User, ChangePasswordRequest } from '@/types/auth'
interface UserProfile {
id: string
name: string
email: string
avatar?: string
phone?: string
address?: string
}
class UserService {
async getProfile() {
const response = await api.get<{ user: User }>('/user/profile')
return response.data.user
}
interface UpdateProfileRequest {
name?: string
phone?: string
address?: string
avatar?: string
}
async updateProfile(data: Partial<User>) {
const response = await api.patch<{ user: User }>('/user/profile', data)
return response.data.user
}
interface ChangePasswordRequest {
currentPassword: string
newPassword: string
}
export const userService = {
async getProfile(): Promise<UserProfile> {
const response = await apiClient.get<UserProfile>('/users/profile')
async changePassword(data: ChangePasswordRequest) {
const response = await api.post<{ successMessage: string }>('/user/change-password', data)
return response.data
},
}
async updateProfile(data: UpdateProfileRequest): Promise<UserProfile> {
const response = await apiClient.patch<UserProfile>('/users/profile', data)
return response.data
},
async changePassword(data: ChangePasswordRequest): Promise<void> {
await apiClient.post('/users/change-password', data)
},
async uploadAvatar(file: File): Promise<{ url: string }> {
async uploadAvatar(file: File) {
const formData = new FormData()
formData.append('avatar', file)
const response = await apiClient.post<{ url: string }>('/users/avatar', formData, {
const response = await api.post<{ url: string }>('/user/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
}
}
}
export const userService = new UserService()

14
src/CellularManagement.WebUI/src/store/layoutSetting.ts

@ -0,0 +1,14 @@
import { atom } from 'recoil';
export type LayoutType = 'slide' | 'slideSplitMenus' | 'left' | 'top' | 'topSplitMenus';
interface LayoutSetting {
layoutValue: LayoutType;
}
export const layoutSettingStore = atom<LayoutSetting>({
key: 'layoutSetting',
default: {
layoutValue: 'left'
}
});

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

@ -33,9 +33,12 @@ export const rolePermissions: Record<Role, Permission[]> = {
export interface User {
id: string
userName: string
name: string
email: string
phoneNumber: string
roles: Role[]
avatar?: string
createdAt: string
updatedAt: string
}
export interface ApiResponse<T> {
@ -62,18 +65,32 @@ export interface LoginRequest {
export interface RegisterRequest {
userName: string
name: string
email: string
password: string
confirmPassword: string
phoneNumber: string
password: string
}
export interface ResetPasswordRequest {
export interface ForgotPasswordRequest {
email: string
}
export interface ResetPasswordConfirmRequest {
export interface ResetPasswordRequest {
token: string
newPassword: string
confirmPassword: string
}
export interface ChangePasswordRequest {
currentPassword: string
newPassword: string
confirmPassword: string
}
export interface AuthState {
user: User | null
token: string | null
login: (user: User, token: string, rememberMe?: boolean) => void
logout: () => void
updateUser: (user: User) => void
}
Loading…
Cancel
Save