Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m17s
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.
461 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|