From 3063a40c8f2fd30acc60c12f4f57bbc17fde3113 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 19:46:55 +0800 Subject: [PATCH] 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 --- web/src/app/router.tsx | 36 +++ web/src/features/admin/use-admin-users.ts | 93 ++++++++ web/src/features/admin/use-audit-log.ts | 44 ++++ web/src/pages/admin/audit-log.tsx | 123 +++++++++++ web/src/pages/admin/users.tsx | 258 ++++++++++++++++++++++ 5 files changed, 554 insertions(+) create mode 100644 web/src/features/admin/use-admin-users.ts create mode 100644 web/src/features/admin/use-audit-log.ts create mode 100644 web/src/pages/admin/audit-log.tsx create mode 100644 web/src/pages/admin/users.tsx diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d888d56b..819ac1d8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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 }) diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts new file mode 100644 index 00000000..f1010023 --- /dev/null +++ b/web/src/features/admin/use-admin-users.ts @@ -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 { + 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(url) +} + +async function updateUserRole(userId: string, role: string): Promise { + 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 { + 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'] }) + }, + }) +} diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts new file mode 100644 index 00000000..7a2a3649 --- /dev/null +++ b/web/src/features/admin/use-audit-log.ts @@ -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 { + 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(url) +} + +export function useAuditLog(params: AuditLogParams) { + return useQuery({ + queryKey: ['admin', 'audit-logs', params], + queryFn: () => getAuditLogs(params), + }) +} diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx new file mode 100644 index 00000000..67399088 --- /dev/null +++ b/web/src/pages/admin/audit-log.tsx @@ -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('') + 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 ( +
+
+

审计日志

+

查看系统操作记录

+
+ + +
+ + setUserIdFilter(e.target.value)} + className="w-[200px]" + /> +
+
+ + {isLoading ? ( +
加载中...
+ ) : !data || data.items.length === 0 ? ( + +

暂无审计日志

+
+ ) : ( + <> + + + + + 时间 + 操作 + 用户 ID + 用户名 + IP 地址 + 详情 + + + + {data.items.map((log) => ( + + {formatDate(log.timestamp)} + {log.action} + {log.userId} + {log.username || '-'} + {log.ipAddress || '-'} + + {log.details || '-'} + + + ))} + +
+
+ +
+

+ 共 {data.total} 条记录,第 {page + 1} 页 +

+
+ + +
+
+ + )} +
+ ) +} diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx new file mode 100644 index 00000000..725f7705 --- /dev/null +++ b/web/src/pages/admin/users.tsx @@ -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('') + const [page, setPage] = useState(0) + const [selectedUser, setSelectedUser] = useState(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 ( +
+
+

用户管理

+

管理平台用户和权限

+
+ + +
+ setSearch(e.target.value)} + className="flex-1" + /> + +
+
+ + {isLoading ? ( +
加载中...
+ ) : !data || data.items.length === 0 ? ( + +

暂无用户数据

+
+ ) : ( + <> + + + + + 用户名 + 邮箱 + 状态 + 角色 + 创建时间 + 操作 + + + + {data.items.map((user) => ( + + {user.username} + {user.email} + + + {user.status === 'ACTIVE' ? '活跃' : '已禁用'} + + + {user.platformRoles.join(', ')} + {formatDate(user.createdAt)} + +
+ + {user.status === 'ACTIVE' ? ( + + ) : ( + + )} +
+
+
+ ))} +
+
+
+ +
+

+ 共 {data.total} 条记录,第 {page + 1} 页 +

+
+ + +
+
+ + )} + + + + + 修改用户角色 + + 为用户 {selectedUser?.username} 分配新角色 + + +
+
+ + +
+
+ + + + +
+
+ + + + + 确认操作 + + 确定要{actionType === 'ban' ? '禁用' : '启用'}用户 {selectedUser?.username} 吗? + + + + + + + + +
+ ) +}