Files
admin/src/pages/Verifications.tsx

458 lines
17 KiB
TypeScript
Raw Normal View History

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>
)
}