Initial commit
This commit is contained in:
458
src/pages/Users.tsx
Normal file
458
src/pages/Users.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { User, Role, PaginatedResponse } from '@/types'
|
||||
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import UserDetail from '@/components/UserDetail'
|
||||
import UserRoleDialog from '@/components/UserRoleDialog'
|
||||
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Loader2 } from 'lucide-react'
|
||||
|
||||
// 状态颜色映射
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-green-500',
|
||||
banned: 'bg-red-500',
|
||||
deleted: 'bg-gray-500',
|
||||
}
|
||||
|
||||
// 状态文本映射
|
||||
const statusText: Record<string, string> = {
|
||||
active: '正常',
|
||||
banned: '已封禁',
|
||||
deleted: '已删除',
|
||||
}
|
||||
|
||||
// 角色颜色映射
|
||||
const roleColors: Record<string, string> = {
|
||||
admin: 'bg-red-500',
|
||||
moderator: 'bg-orange-500',
|
||||
user: 'bg-blue-500',
|
||||
super_admin: 'bg-purple-500',
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
// 状态
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// 详情对话框
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<UserDetailType | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 角色对话框
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
|
||||
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: UserQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keyword: keyword || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
}
|
||||
const response = await usersApi.getUsers(params)
|
||||
const data = response.data.data as PaginatedResponse<User>
|
||||
setUsers(data.list || [])
|
||||
setTotal(data.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, keyword, statusFilter])
|
||||
|
||||
// 加载所有角色
|
||||
const loadAllRoles = useCallback(async () => {
|
||||
try {
|
||||
const { rolesApi } = await import('@/api')
|
||||
const response = await rolesApi.getRoles()
|
||||
const roles = response.data.data || []
|
||||
setAllRoles(roles.map(r => ({
|
||||
id: r.name, // 使用 name 作为 id
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
created_at: '', // 后端列表接口不返回这些字段
|
||||
updated_at: '',
|
||||
})))
|
||||
} catch (error) {
|
||||
console.error('Failed to load roles:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
// 加载角色列表(仅一次)
|
||||
useEffect(() => {
|
||||
loadAllRoles()
|
||||
}, [loadAllRoles])
|
||||
|
||||
// 查看用户详情
|
||||
const handleViewDetail = async (user: User) => {
|
||||
setDetailLoading(true)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const response = await usersApi.getUserById(user.id)
|
||||
setSelectedUser(response.data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user detail:', error)
|
||||
setSelectedUser(null)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开角色分配对话框
|
||||
const handleOpenRoleDialog = (user: User) => {
|
||||
setRoleDialogUser(user)
|
||||
setRoleDialogOpen(true)
|
||||
}
|
||||
|
||||
// 分配角色
|
||||
const handleAssignRole = async (userId: string, role: string) => {
|
||||
await usersApi.assignRole(userId, role)
|
||||
// 刷新用户列表
|
||||
loadUsers()
|
||||
// 如果详情对话框打开,也刷新详情
|
||||
if (selectedUser?.id === userId) {
|
||||
const response = await usersApi.getUserById(userId)
|
||||
setSelectedUser(response.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除角色
|
||||
const handleRemoveRole = async (userId: string, role: string) => {
|
||||
await usersApi.removeRole(userId, role)
|
||||
// 刷新用户列表
|
||||
loadUsers()
|
||||
// 如果详情对话框打开,也刷新详情
|
||||
if (selectedUser?.id === userId) {
|
||||
const response = await usersApi.getUserById(userId)
|
||||
setSelectedUser(response.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换用户状态
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
const newStatus = user.status === 'active' ? 'banned' : 'active'
|
||||
try {
|
||||
await usersApi.updateUserStatus(user.id, newStatus)
|
||||
// 刷新用户列表
|
||||
loadUsers()
|
||||
// 如果详情对话框打开,也刷新详情
|
||||
if (selectedUser?.id === user.id) {
|
||||
const response = await usersApi.getUserById(user.id)
|
||||
setSelectedUser(response.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update user status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
// 获取头像fallback文字
|
||||
const getInitials = (name: string) => {
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">用户管理</h1>
|
||||
<p className="text-muted-foreground">管理系统用户账户</p>
|
||||
</div>
|
||||
<Button onClick={handleRefresh} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>筛选条件</CardTitle>
|
||||
<CardDescription>根据条件筛选用户</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex gap-2 flex-1 min-w-[300px]">
|
||||
<Input
|
||||
placeholder="搜索用户名或邮箱..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">正常</SelectItem>
|
||||
<SelectItem value="banned">已封禁</SelectItem>
|
||||
<SelectItem value="deleted">已删除</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户表格 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 个用户
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">ID</TableHead>
|
||||
<TableHead className="w-[60px]">头像</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>注册时间</TableHead>
|
||||
<TableHead className="w-[200px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-2">加载中...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetail(user)}
|
||||
>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} alt={user.nickname || user.username} />
|
||||
<AvatarFallback>{getInitials(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{user.nickname || user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">@{user.username}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[user.status] || 'bg-gray-500'}>
|
||||
{statusText[user.status] || user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles?.slice(0, 2).map((role: Role) => (
|
||||
<Badge key={role.id} className={roleColors[role.name] || 'bg-gray-500'} variant="secondary">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
{user.roles && user.roles.length > 2 && (
|
||||
<Badge variant="outline">+{user.roles.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.created_at)}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(user)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenRoleDialog(user)}
|
||||
title="分配角色"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
title="封禁用户"
|
||||
>
|
||||
<Ban className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
) : user.status === 'banned' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
title="解除封禁"
|
||||
>
|
||||
<Unlock className="h-4 w-4 text-green-500" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>用户详情</DialogTitle>
|
||||
<DialogDescription>查看用户详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedUser ? (
|
||||
<UserDetail
|
||||
user={selectedUser}
|
||||
onAssignRole={() => {
|
||||
setDetailOpen(false)
|
||||
setRoleDialogUser({
|
||||
id: selectedUser.id,
|
||||
username: selectedUser.username,
|
||||
nickname: selectedUser.nickname,
|
||||
email: selectedUser.email,
|
||||
avatar: selectedUser.avatar,
|
||||
status: selectedUser.status,
|
||||
roles: selectedUser.roles,
|
||||
created_at: selectedUser.created_at,
|
||||
updated_at: selectedUser.updated_at,
|
||||
})
|
||||
setRoleDialogOpen(true)
|
||||
}}
|
||||
onToggleStatus={() => handleToggleStatus(selectedUser)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">加载失败</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 角色分配对话框 */}
|
||||
{roleDialogUser && (
|
||||
<UserRoleDialog
|
||||
open={roleDialogOpen}
|
||||
onOpenChange={setRoleDialogOpen}
|
||||
userId={roleDialogUser.id}
|
||||
username={roleDialogUser.nickname || roleDialogUser.username}
|
||||
currentRoles={roleDialogUser.roles || []}
|
||||
allRoles={allRoles}
|
||||
onAssignRole={handleAssignRole}
|
||||
onRemoveRole={handleRemoveRole}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user