feat(web): add admin management pages

- Add admin users management page with role and status controls
- Add audit log page with filtering capabilities
- Create useAdminUsers and useAuditLog hooks for API integration
- Add route guards for USER_ADMIN, AUDITOR, and SUPER_ADMIN roles
- Register /admin/users and /admin/audit-log routes
This commit is contained in:
vsxd 2026-03-12 19:46:55 +08:00
parent c0e9c10e15
commit 3063a40c8f
5 changed files with 554 additions and 0 deletions

View file

@ -13,6 +13,8 @@ import { NamespaceMembersPage } from '@/pages/dashboard/namespace-members'
import { ReviewsPage } from '@/pages/dashboard/reviews'
import { ReviewDetailPage } from '@/pages/dashboard/review-detail'
import { DeviceAuthPage } from '@/pages/device'
import { AdminUsersPage } from '@/pages/admin/users'
import { AuditLogPage } from '@/pages/admin/audit-log'
import { getCurrentUser } from '@/api/client'
const rootRoute = createRootRoute({
@ -153,6 +155,38 @@ const deviceRoute = createRoute({
component: DeviceAuthPage,
})
const adminUsersRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/users',
beforeLoad: async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
component: AdminUsersPage,
})
const adminAuditLogRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/audit-log',
beforeLoad: async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
component: AuditLogPage,
})
const routeTree = rootRoute.addChildren([
homeRoute,
loginRoute,
@ -167,6 +201,8 @@ const routeTree = rootRoute.addChildren([
dashboardReviewsRoute,
dashboardReviewDetailRoute,
deviceRoute,
adminUsersRoute,
adminAuditLogRoute,
])
export const router = createRouter({ routeTree })

View file

@ -0,0 +1,93 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/api/client'
export interface AdminUser {
id: string
username: string
email: string
status: 'ACTIVE' | 'DISABLED'
platformRoles: string[]
createdAt: string
}
export interface AdminUsersParams {
search?: string
status?: string
page?: number
size?: number
}
export interface PagedAdminUsers {
items: AdminUser[]
total: number
page: number
size: number
}
async function getAdminUsers(params: AdminUsersParams): Promise<PagedAdminUsers> {
const searchParams = new URLSearchParams()
if (params.search) searchParams.set('search', params.search)
if (params.status) searchParams.set('status', params.status)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
const url = `/api/v1/admin/users?${searchParams.toString()}`
return fetchJson<PagedAdminUsers>(url)
}
async function updateUserRole(userId: string, role: string): Promise<void> {
const csrfToken = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)?.[1]
const res = await fetch(`/api/v1/admin/users/${userId}/role`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-XSRF-TOKEN': decodeURIComponent(csrfToken) } : {}),
},
body: JSON.stringify({ role }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
}
async function updateUserStatus(userId: string, status: 'ACTIVE' | 'DISABLED'): Promise<void> {
const csrfToken = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)?.[1]
const res = await fetch(`/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-XSRF-TOKEN': decodeURIComponent(csrfToken) } : {}),
},
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
}
export function useAdminUsers(params: AdminUsersParams) {
return useQuery({
queryKey: ['admin', 'users', params],
queryFn: () => getAdminUsers(params),
})
}
export function useUpdateUserRole() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
updateUserRole(userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
},
})
}
export function useUpdateUserStatus() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ userId, status }: { userId: string; status: 'ACTIVE' | 'DISABLED' }) =>
updateUserStatus(userId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
},
})
}

View file

@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { fetchJson } from '@/api/client'
export interface AuditLog {
id: number
action: string
userId: string
username?: string
details?: string
ipAddress?: string
timestamp: string
}
export interface AuditLogParams {
action?: string
userId?: string
page?: number
size?: number
}
export interface PagedAuditLogs {
items: AuditLog[]
total: number
page: number
size: number
}
async function getAuditLogs(params: AuditLogParams): Promise<PagedAuditLogs> {
const searchParams = new URLSearchParams()
if (params.action) searchParams.set('action', params.action)
if (params.userId) searchParams.set('userId', params.userId)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
const url = `/api/v1/admin/audit-logs?${searchParams.toString()}`
return fetchJson<PagedAuditLogs>(url)
}
export function useAuditLog(params: AuditLogParams) {
return useQuery({
queryKey: ['admin', 'audit-logs', params],
queryFn: () => getAuditLogs(params),
})
}

View file

@ -0,0 +1,123 @@
import { useState } from 'react'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Select } from '@/shared/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/shared/ui/table'
import { useAuditLog } from '@/features/admin/use-audit-log'
export function AuditLogPage() {
const [actionFilter, setActionFilter] = useState<string>('')
const [userIdFilter, setUserIdFilter] = useState('')
const [page, setPage] = useState(0)
const { data, isLoading } = useAuditLog({
action: actionFilter || undefined,
userId: userIdFilter || undefined,
page,
size: 20,
})
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">审计日志</h1>
<p className="text-muted-foreground">查看系统操作记录</p>
</div>
<Card className="p-4">
<div className="flex gap-4">
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
<option value="">全部</option>
<option value="USER_LOGIN">用户登录</option>
<option value="USER_LOGOUT">用户登出</option>
<option value="USER_ROLE_CHANGE">角色变更</option>
<option value="USER_STATUS_CHANGE">状态变更</option>
<option value="SKILL_PUBLISH">技能发布</option>
<option value="SKILL_REVIEW">技能审核</option>
</Select>
<Input
placeholder="用户 ID..."
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="w-[200px]"
/>
</div>
</Card>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground">暂无审计日志</p>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>操作</TableHead>
<TableHead>用户 ID</TableHead>
<TableHead>用户名</TableHead>
<TableHead>IP 地址</TableHead>
<TableHead>详情</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell>{formatDate(log.timestamp)}</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>{log.userId}</TableCell>
<TableCell>{log.username || '-'}</TableCell>
<TableCell>{log.ipAddress || '-'}</TableCell>
<TableCell className="max-w-md truncate">
{log.details || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
共 {data.total} 条记录,第 {page + 1} 页
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
下一页
</Button>
</div>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,258 @@
import { useState } from 'react'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Select } from '@/shared/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/shared/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Label } from '@/shared/ui/label'
import { useAdminUsers, useUpdateUserRole, useUpdateUserStatus } from '@/features/admin/use-admin-users'
import type { AdminUser } from '@/features/admin/use-admin-users'
export function AdminUsersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [page, setPage] = useState(0)
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [newRole, setNewRole] = useState('')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [actionType, setActionType] = useState<'ban' | 'unban'>('ban')
const { data, isLoading } = useAdminUsers({
search,
status: statusFilter || undefined,
page,
size: 20,
})
const updateRoleMutation = useUpdateUserRole()
const updateStatusMutation = useUpdateUserStatus()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
}
const handleChangeRole = (user: AdminUser) => {
setSelectedUser(user)
setNewRole(user.platformRoles[0] || '')
setRoleDialogOpen(true)
}
const handleToggleStatus = (user: AdminUser, action: 'ban' | 'unban') => {
setSelectedUser(user)
setActionType(action)
setConfirmDialogOpen(true)
}
const confirmRoleChange = async () => {
if (!selectedUser) return
try {
await updateRoleMutation.mutateAsync({ userId: selectedUser.id, role: newRole })
setRoleDialogOpen(false)
setSelectedUser(null)
} catch (error) {
console.error('Failed to update role:', error)
}
}
const confirmStatusChange = async () => {
if (!selectedUser) return
try {
const newStatus = actionType === 'ban' ? 'DISABLED' : 'ACTIVE'
await updateStatusMutation.mutateAsync({ userId: selectedUser.id, status: newStatus })
setConfirmDialogOpen(false)
setSelectedUser(null)
} catch (error) {
console.error('Failed to update status:', error)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">用户管理</h1>
<p className="text-muted-foreground">管理平台用户和权限</p>
</div>
<Card className="p-4">
<div className="flex gap-4">
<Input
placeholder="搜索用户名或邮箱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">全部</option>
<option value="ACTIVE">活跃</option>
<option value="DISABLED">已禁用</option>
</Select>
</div>
</Card>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground">暂无用户数据</p>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>用户名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.status === 'ACTIVE' ? '活跃' : '已禁用'}
</span>
</TableCell>
<TableCell>{user.platformRoles.join(', ')}</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleChangeRole(user)}
>
修改角色
</Button>
{user.status === 'ACTIVE' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleToggleStatus(user, 'ban')}
>
禁用
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleToggleStatus(user, 'unban')}
>
启用
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
共 {data.total} 条记录,第 {page + 1} 页
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
下一页
</Button>
</div>
</div>
</>
)}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改用户角色</DialogTitle>
<DialogDescription>
为用户 {selectedUser?.username} 分配新角色
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="role">角色</Label>
<Select id="role" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
<option value="">选择角色</option>
<option value="USER">普通用户</option>
<option value="REVIEWER">审核员</option>
<option value="USER_ADMIN">用户管理员</option>
<option value="AUDITOR">审计员</option>
<option value="SUPER_ADMIN">超级管理员</option>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
取消
</Button>
<Button onClick={confirmRoleChange} disabled={updateRoleMutation.isPending}>
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认操作</DialogTitle>
<DialogDescription>
确定要{actionType === 'ban' ? '禁用' : '启用'}用户 {selectedUser?.username} 吗?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>
取消
</Button>
<Button onClick={confirmStatusChange} disabled={updateStatusMutation.isPending}>
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}