Files
admin/src/pages/Verifications.tsx
lafay 0f86e5b9ee
Some checks failed
Admin CI / build-and-push-web (push) Failing after 6m11s
feat(verifications): add identity verification management for admin panel
Add new verifications module including API endpoints for retrieving and reviewing user identity verifications, along with corresponding admin page, routing, and sidebar navigation entry.
2026-04-05 20:26:57 +08:00

458 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}