458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|