mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
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:
parent
c0e9c10e15
commit
3063a40c8f
5 changed files with 554 additions and 0 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
93
web/src/features/admin/use-admin-users.ts
Normal file
93
web/src/features/admin/use-admin-users.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
44
web/src/features/admin/use-audit-log.ts
Normal file
44
web/src/features/admin/use-audit-log.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
123
web/src/pages/admin/audit-log.tsx
Normal file
123
web/src/pages/admin/audit-log.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
web/src/pages/admin/users.tsx
Normal file
258
web/src/pages/admin/users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue