feat(verifications): add identity verification management for admin panel
Some checks failed
Admin CI / build-and-push-web (push) Failing after 6m11s

Add new verifications module including API endpoints for retrieving and reviewing user identity verifications, along with corresponding admin page, routing, and sidebar navigation entry.
This commit is contained in:
lafay
2026-04-05 20:26:57 +08:00
parent 00b79681a0
commit 0f86e5b9ee
5 changed files with 530 additions and 0 deletions

View File

@@ -18,3 +18,5 @@ export { subjectsApi, materialsApi } from './materials'
export type { MaterialSubject, MaterialFile, MaterialFileType, SubjectUpsertRequest, MaterialCreateRequest, MaterialUpdateRequest, MaterialQueryParams } from './materials'
export { reportsApi } from './reports'
export type { ReportListItem, ReportDetail, ReportQueryParams, ReportStatus, ReportTargetType, HandleReportRequest, BatchHandleRequest, BatchHandleResponse } from './reports'
export { verificationsApi } from './verifications'
export type { VerificationListItem, VerificationDetail, VerificationQueryParams, VerificationStatus, UserIdentity, ReviewVerificationRequest } from './verifications'

53
src/api/verifications.ts Normal file
View File

@@ -0,0 +1,53 @@
import apiClient from './client'
import type { ApiResponse, PaginatedResponse, User } from '@/types'
export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external'
export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected'
export interface VerificationListItem {
id: string
user_id: string
username: string
nickname: string
avatar: string
identity: UserIdentity
status: VerificationStatus
real_name: string
student_id?: string
employee_id?: string
department?: string
created_at: string
reviewed_at?: string
reviewer_name?: string
}
export interface VerificationDetail extends VerificationListItem {
proof_materials: string
reviewed_by?: string
reject_reason?: string
}
export interface VerificationQueryParams {
page?: number
page_size?: number
status?: VerificationStatus
keyword?: string
}
export interface ReviewVerificationRequest {
approve: boolean
reject_reason?: string
}
export const verificationsApi = {
getVerifications: (params: VerificationQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<VerificationListItem>>>('/admin/verifications', { params }),
getVerificationById: (id: string) =>
apiClient.get<ApiResponse<VerificationDetail>>(`/admin/verifications/${id}`),
reviewVerification: (id: string, data: ReviewVerificationRequest) =>
apiClient.put<ApiResponse<VerificationDetail>>(`/admin/verifications/${id}/review`, data),
}
export default verificationsApi

View File

@@ -15,6 +15,7 @@ import {
ChevronDown,
Layers,
Flag,
ShieldCheck,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
@@ -75,6 +76,12 @@ const menuItems: MenuItem[] = [
icon: Flag,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
{
title: '身份认证',
path: '/verifications',
icon: ShieldCheck,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
},
{
title: '频道管理',
path: '/channels',

457
src/pages/Verifications.tsx Normal file
View File

@@ -0,0 +1,457 @@
import { useState, useEffect, useCallback } from 'react'
import { verificationsApi, type VerificationListItem, type VerificationDetail, type VerificationStatus } 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 { Textarea } from '@/components/ui/textarea'
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,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Search, RefreshCw, Eye, CheckCircle, XCircle, Loader2 } from 'lucide-react'
const statusColors: Record<VerificationStatus, string> = {
not_submitted: 'bg-gray-500',
pending: 'bg-yellow-500',
approved: 'bg-green-500',
rejected: 'bg-red-500',
}
const statusText: Record<VerificationStatus, string> = {
not_submitted: '未提交',
pending: '待审核',
approved: '已通过',
rejected: '已拒绝',
}
const identityText: Record<string, string> = {
student: '学生',
teacher: '教师',
staff: '职工',
alumni: '校友',
external: '外部人员',
general: '普通用户',
}
export default function Verifications() {
const [verifications, setVerifications] = useState<VerificationListItem[]>([])
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 [selectedVerification, setSelectedVerification] = useState<VerificationDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [reviewDialogOpen, setReviewDialogOpen] = useState(false)
const [reviewLoading, setReviewLoading] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const loadVerifications = useCallback(async () => {
setLoading(true)
try {
const params = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter as VerificationStatus : undefined,
}
const response = await verificationsApi.getVerifications(params)
setVerifications(response.data.data.list || [])
setTotal(response.data.data.total || 0)
} catch (error) {
console.error('Failed to load verifications:', error)
setVerifications([])
setTotal(0)
} finally {
setLoading(false)
}
}, [page, keyword, statusFilter])
useEffect(() => {
loadVerifications()
}, [loadVerifications])
const handleViewDetail = async (verification: VerificationListItem) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await verificationsApi.getVerificationById(verification.id)
setSelectedVerification(response.data.data)
} catch (error) {
console.error('Failed to load verification detail:', error)
setSelectedVerification(null)
} finally {
setDetailLoading(false)
}
}
const handleOpenReviewDialog = () => {
setRejectReason('')
setReviewDialogOpen(true)
}
const handleReview = async (approve: boolean) => {
if (!selectedVerification) return
if (!approve && !rejectReason.trim()) {
alert('请填写拒绝原因')
return
}
setReviewLoading(true)
try {
await verificationsApi.reviewVerification(selectedVerification.id, {
approve,
reject_reason: approve ? undefined : rejectReason,
})
setReviewDialogOpen(false)
setDetailOpen(false)
loadVerifications()
} catch (error) {
console.error('Failed to review verification:', error)
alert('审核失败')
} finally {
setReviewLoading(false)
}
}
const handleSearch = () => {
setPage(1)
loadVerifications()
}
const handleRefresh = () => {
loadVerifications()
}
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
const totalPages = Math.ceil(total / pageSize)
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',
hour: '2-digit',
minute: '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="pending"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="rejected"></SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> {total} </CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></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>
) : verifications.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
verifications.map((verification) => (
<TableRow
key={verification.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetail(verification)}
>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={verification.avatar} alt={verification.nickname} />
<AvatarFallback>{getInitials(verification.nickname)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{verification.nickname}</p>
<p className="text-xs text-muted-foreground">@{verification.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{identityText[verification.identity] || verification.identity}</Badge>
</TableCell>
<TableCell>{verification.real_name}</TableCell>
<TableCell>{verification.student_id || verification.employee_id || '-'}</TableCell>
<TableCell>{verification.department || '-'}</TableCell>
<TableCell>
<Badge className={statusColors[verification.status]}>
{statusText[verification.status]}
</Badge>
</TableCell>
<TableCell>{formatDate(verification.created_at)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(verification)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
</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>
) : selectedVerification ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={selectedVerification.avatar} alt={selectedVerification.nickname} />
<AvatarFallback>{getInitials(selectedVerification.nickname)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{selectedVerification.nickname}</p>
<p className="text-sm text-muted-foreground">@{selectedVerification.username}</p>
</div>
<Badge className={`ml-auto ${statusColors[selectedVerification.status]}`}>
{statusText[selectedVerification.status]}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{identityText[selectedVerification.identity] || selectedVerification.identity}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{selectedVerification.real_name}</p>
</div>
{selectedVerification.student_id && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{selectedVerification.student_id}</p>
</div>
)}
{selectedVerification.employee_id && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{selectedVerification.employee_id}</p>
</div>
)}
{selectedVerification.department && (
<div className="col-span-2">
<p className="text-muted-foreground">/</p>
<p className="font-medium">{selectedVerification.department}</p>
</div>
)}
<div className="col-span-2">
<p className="text-muted-foreground mb-2"></p>
<div className="border rounded-lg p-2">
<img
src={selectedVerification.proof_materials}
alt="证明材料"
className="max-w-full h-auto rounded"
/>
</div>
</div>
{selectedVerification.reviewed_at && (
<>
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{formatDate(selectedVerification.reviewed_at)}</p>
</div>
{selectedVerification.reviewer_name && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-medium">{selectedVerification.reviewer_name}</p>
</div>
)}
</>
)}
{selectedVerification.reject_reason && (
<div className="col-span-2">
<p className="text-muted-foreground"></p>
<p className="font-medium text-red-600">{selectedVerification.reject_reason}</p>
</div>
)}
</div>
{selectedVerification.status === 'pending' && (
<div className="flex gap-2 pt-4">
<Button
className="flex-1"
onClick={handleOpenReviewDialog}
>
<CheckCircle className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</div>
) : (
<p className="text-center text-muted-foreground py-8"></p>
)}
</DialogContent>
</Dialog>
{/* 审核对话框 */}
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<p className="text-sm font-medium mb-2"></p>
<Textarea
placeholder="请输入拒绝原因..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => handleReview(false)}
disabled={reviewLoading}
className="text-red-600 border-red-600 hover:bg-red-50"
>
{reviewLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <XCircle className="h-4 w-4 mr-2" />}
</Button>
<Button
onClick={() => handleReview(true)}
disabled={reviewLoading}
>
{reviewLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <CheckCircle className="h-4 w-4 mr-2" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -107,6 +107,7 @@ const Channels = lazy(() => import('@/pages/Channels'))
const Reports = lazy(() => import('@/pages/Reports'))
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
const Materials = lazy(() => import('@/pages/Materials'))
const Verifications = lazy(() => import('@/pages/Verifications'))
const Forbidden = lazy(() => import('@/pages/Forbidden'))
const NotFound = lazy(() => import('@/pages/NotFound'))
@@ -235,6 +236,16 @@ export const router = createBrowserRouter([
</AdminGuard>
),
},
{
path: 'verifications',
element: (
<AdminGuard>
<Suspense fallback={<PageLoader />}>
<Verifications />
</Suspense>
</AdminGuard>
),
},
],
},
{