feat: user info page

This commit is contained in:
VaalaCat
2025-05-02 15:28:46 +00:00
parent 3e5bec4355
commit 6949e1305a
5 changed files with 400 additions and 21 deletions

View File

@@ -1,10 +1,11 @@
"use client"
'use client'
import {
ChevronsUpDown,
LogOut,
} from "lucide-react"
User as UserIcon, // 别名避免和 User 类型冲突
} from 'lucide-react'
import Link from 'next/link'
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,26 +13,21 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
} from '@/components/ui/dropdown-menu'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
import { User } from '@/lib/pb/common'
import { Avatar } from "./ui/avatar"
import { UserAvatar } from "./base/avatar"
import { $token, $userInfo } from "@/store/user"
import { logout } from "@/api/auth"
import { useTranslation } from 'react-i18next';
import { Avatar } from './ui/avatar'
import { UserAvatar } from './base/avatar'
import { $token, $userInfo } from '@/store/user'
import { logout } from '@/api/auth'
import { useTranslation } from 'react-i18next'
export interface NavUserProps {
user: User
}
export function NavUser({ user }: NavUserProps) {
const { t } = useTranslation();
const { t } = useTranslation()
const { isMobile } = useSidebar()
return (
@@ -55,7 +51,7 @@ export function NavUser({ user }: NavUserProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
@@ -70,16 +66,30 @@ export function NavUser({ user }: NavUserProps) {
</div>
</div>
</DropdownMenuLabel>
{/* 使用 next/link 创建 “User Info” 菜单项 */}
<DropdownMenuItem asChild>
<Link href="/user-info" className="w-full flex items-center space-x-2">
<UserIcon className="h-4 w-4" />
<span>{t('common.userInfo')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<div onClick={
async () => {
<div
onClick={async () => {
$userInfo.set(undefined)
$token.set(undefined)
await logout()
window.location.reload()
}
} className="w-full flex flex-row space-x-2 items-center"><LogOut className="h-4 w-4" /><p>{t('common.logout')}</p></div>
}}
className="w-full flex items-center space-x-4"
>
<LogOut className="h-4 w-4" />
<span>{t('common.logout')}</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,298 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { getUserInfo, updateUserInfo } from '@/api/user'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter as DialogFooterUI,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { UserAvatar } from '@/components/base/avatar'
import { toast } from 'sonner'
import { User } from '@/lib/pb/common'
import { useTranslation } from 'react-i18next'
const userSchema = z
.object({
UserID: z.string().optional(),
TenantID: z.string().optional(),
UserName: z.string().min(2, 'Name too short'),
Email: z.string().email('Invalid email address'),
Status: z.string().optional(),
Role: z.string().optional(),
NewPassword: z.string().optional(),
ConfirmPassword: z.string().optional(),
})
.superRefine((data, ctx) => {
const np = data.NewPassword
const cp = data.ConfirmPassword
if (np) {
if (np.length < 6) {
ctx.addIssue({
path: ['NewPassword'],
message: 'Password must be at least 6 characters',
code: z.ZodIssueCode.custom,
})
}
if (np !== cp) {
ctx.addIssue({
path: ['ConfirmPassword'],
message: 'Passwords do not match',
code: z.ZodIssueCode.custom,
})
}
}
})
type UserFormValues = z.infer<typeof userSchema>
export function UserProfileForm() {
const { t } = useTranslation()
const [loading, setLoading] = useState(true)
const [initial, setInitial] = useState<UserFormValues | null>(null)
const [open, setOpen] = useState(false)
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
UserID: '',
TenantID: '',
UserName: '',
Email: '',
Role: '',
NewPassword: '',
ConfirmPassword: '',
},
})
// Fetch on mount
useEffect(() => {
getUserInfo({})
.then((res) => {
const u = res.userInfo! as User
form.reset({
UserID: u.userID?.toString(),
TenantID: u.tenantID?.toString(),
UserName: u.userName || '',
Email: u.email || '',
Role: u.role || '',
NewPassword: '',
ConfirmPassword: '',
})
setInitial(form.getValues())
})
.finally(() => setLoading(false))
}, [])
const onSubmit = async (values: UserFormValues) => {
try {
const payload: any = {
userID: values.UserID ? BigInt(values.UserID) : undefined,
tenantID: values.TenantID ? BigInt(values.TenantID) : undefined,
userName: values.UserName,
email: values.Email,
}
if (values.NewPassword) {
payload.rawPassword = values.NewPassword
}
await updateUserInfo({ userInfo: payload })
toast.success(t('userInfo.profileUpdated'))
form.reset({
...values,
NewPassword: '',
ConfirmPassword: '',
})
setInitial(form.getValues())
} catch {
toast(t('userInfo.updateFailed'))
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">{t('userInfo.loading')}</p>
</div>
)
}
return (
<Card className="max-w-lg mx-auto">
<CardHeader className="flex flex-row items-center space-x-4 border-b">
<Avatar className="h-12 w-12 rounded-lg">
<UserAvatar
className="h-12 w-12"
userInfo={{ userName: form.getValues().UserName, email: form.getValues().Email } as User}
/>
</Avatar>
<div>
<CardTitle>{t('userInfo.yourProfile')}</CardTitle>
<CardDescription>{t('userInfo.manageAccountDetails')}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* UserID (read-only) */}
<FormField
control={form.control}
name="UserID"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.userID')}</FormLabel>
<FormControl>
<Input {...field} disabled className="bg-gray-100 dark:bg-gray-700" />
</FormControl>
</FormItem>
)}
/>
{/* TenantID (read-only) */}
<FormField
control={form.control}
name="TenantID"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.tenantID')}</FormLabel>
<FormControl>
<Input {...field} disabled className="bg-gray-100 dark:bg-gray-700" />
</FormControl>
</FormItem>
)}
/>
{/* UserName */}
<FormField
control={form.control}
name="UserName"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.name')}</FormLabel>
<FormControl>
<Input placeholder={t('userInfo.placeholderName')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="Email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.email')}</FormLabel>
<FormControl>
<Input type="email" placeholder={t('userInfo.placeholderEmail')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Role (read-only badge style) */}
<FormField
control={form.control}
name="Role"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>{t('userInfo.role')}</FormLabel>
<FormDescription>{t('userInfo.roleDescription')}</FormDescription>
<FormControl>
<Input
{...field}
disabled
className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
/>
</FormControl>
</FormItem>
)}
/>
{/* Change Password Section */}
<div className="md:col-span-2">
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300">{t('userInfo.changePassword')}</h3>
</div>
{/* New Password */}
<FormField
control={form.control}
name="NewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.newPassword')}</FormLabel>
<FormControl>
<Input type="password" placeholder={t('userInfo.placeholderNewPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Confirm Password */}
<FormField
control={form.control}
name="ConfirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('userInfo.confirmPassword')}</FormLabel>
<FormControl>
<Input type="password" placeholder={t('userInfo.placeholderConfirmNewPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-end">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
disabled={form.formState.isSubmitting || JSON.stringify(form.getValues()) === JSON.stringify(initial)}
onClick={() => setOpen(true)}
>
{t('userInfo.saveChanges')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('userInfo.confirmSaveTitle')}</DialogTitle>
<DialogDescription>{t('userInfo.confirmSaveDescription')}</DialogDescription>
</DialogHeader>
<DialogFooterUI>
<Button variant={'destructive'} onClick={() => setOpen(false)}>
{t('userInfo.cancel')}
</Button>
<Button
variant={'secondary'}
onClick={() => {
form.handleSubmit(onSubmit)()
setOpen(false)
}}
>
{t('userInfo.confirm')}
</Button>
</DialogFooterUI>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
)
}

View File

@@ -175,6 +175,7 @@
"submit": "Submit",
"login": "Login",
"register": "Register",
"userInfo": "User Information",
"logout": "Logout",
"clientType": "Client Type",
"disconnect": "Disconnect",
@@ -444,6 +445,31 @@
"team": {
"title": "Teams"
},
"userInfo": {
"profileUpdated": "Profile Updated",
"updateFailed": "Update Failed",
"loading": "Loading",
"yourProfile": "Your Profile",
"manageAccountDetails": "Manage Account Details",
"userID": "User ID",
"tenantID": "Tenant ID",
"name": "Name",
"placeholderName": "Enter your name",
"email": "Email",
"placeholderEmail": "Enter your email",
"role": "Role",
"roleDescription": "Administrator(admin) or Normal User(normal)",
"changePassword": "Change Password",
"newPassword": "New Password",
"placeholderNewPassword": "Enter your new password",
"confirmPassword": "Confirm Password",
"placeholderConfirmNewPassword": "Confirm your new password",
"saveChanges": "Save Changes",
"confirmSaveTitle": "Confirm Save",
"confirmSaveDescription": "Are you sure you want to save your changes?",
"cancel": "Cancel",
"confirm": "Confirm"
},
"nav": {
"clients": "Clients",
"servers": "Servers",

View File

@@ -175,6 +175,7 @@
"submit": "提交",
"login": "登录",
"register": "注册",
"userInfo": "用户信息",
"logout": "退出登录",
"clientType": "客户端类型",
"disconnect": "断开连接",
@@ -442,6 +443,31 @@
"team": {
"title": "租户"
},
"userInfo": {
"profileUpdated": "用户信息已更新",
"updateFailed": "更新用户信息失败",
"loading": "正在加载用户信息",
"yourProfile": "您的用户信息",
"manageAccountDetails": "管理账户详情",
"userID": "用户ID",
"tenantID": "租户ID",
"name": "姓名",
"placeholderName": "请输入姓名",
"email": "邮箱",
"placeholderEmail": "请输入邮箱",
"role": "角色",
"roleDescription": "管理员(admin)或普通用户(normal)",
"changePassword": "修改密码",
"newPassword": "新密码",
"placeholderNewPassword": "请输入新密码",
"confirmPassword": "确认新密码",
"placeholderConfirmNewPassword": "请再次输入新密码",
"saveChanges": "保存更改",
"confirmSaveTitle": "确认保存",
"confirmSaveDescription": "您确定要保存更改吗?",
"cancel": "取消",
"confirm": "确认"
},
"nav": {
"clients": "客户端",
"servers": "服务端",

19
www/pages/user-info.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Providers } from '@/components/providers'
import { RootLayout } from '@/components/layout'
import { Header } from '@/components/header'
import { UserProfileForm } from '@/components/user/user-info'
export default function ClientListPage() {
return (
<Providers>
<RootLayout mainHeader={<Header />}>
<div className="w-full">
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-row mb-2 gap-2"></div>
</div>
<UserProfileForm />
</div>
</RootLayout>
</Providers>
)
}