Files
admin/src/pages/Users.tsx
lafay 4991dae691
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m17s
feat(types): add image types and enhance Post/Comment interfaces
Add PostImage and CommentImage types for handling post/comment images. Update
Post interface with likes_count, comments_count, views_count, and images.
Update Comment interface with likes_count, replies_count, and images. Add
display_name and priority to Role type. Include dynamic color CSS utilities
and UI enhancements for Comments/Posts pages. Add defensive empty array
fallback for subjects data.
2026-04-10 01:11:15 +08:00

461 lines
15 KiB
TypeScript

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: r.name,
display_name: r.display_name,
description: r.description,
priority: r.priority,
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>
)
}