diff --git a/src/api/index.ts b/src/api/index.ts index 7494f3a..806d1ac 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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' diff --git a/src/api/verifications.ts b/src/api/verifications.ts new file mode 100644 index 0000000..2d2fec7 --- /dev/null +++ b/src/api/verifications.ts @@ -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>>('/admin/verifications', { params }), + + getVerificationById: (id: string) => + apiClient.get>(`/admin/verifications/${id}`), + + reviewVerification: (id: string, data: ReviewVerificationRequest) => + apiClient.put>(`/admin/verifications/${id}/review`, data), +} + +export default verificationsApi diff --git a/src/components/Layout/Sidebar.tsx b/src/components/Layout/Sidebar.tsx index 068fe68..9f94751 100644 --- a/src/components/Layout/Sidebar.tsx +++ b/src/components/Layout/Sidebar.tsx @@ -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', diff --git a/src/pages/Verifications.tsx b/src/pages/Verifications.tsx new file mode 100644 index 0000000..ab08892 --- /dev/null +++ b/src/pages/Verifications.tsx @@ -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 = { + not_submitted: 'bg-gray-500', + pending: 'bg-yellow-500', + approved: 'bg-green-500', + rejected: 'bg-red-500', +} + +const statusText: Record = { + not_submitted: '未提交', + pending: '待审核', + approved: '已通过', + rejected: '已拒绝', +} + +const identityText: Record = { + student: '学生', + teacher: '教师', + staff: '职工', + alumni: '校友', + external: '外部人员', + general: '普通用户', +} + +export default function Verifications() { + const [verifications, setVerifications] = useState([]) + 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('all') + + const [detailOpen, setDetailOpen] = useState(false) + const [selectedVerification, setSelectedVerification] = useState(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 ( +
+
+
+

身份认证管理

+

审核用户身份认证申请

+
+ +
+ + + + 筛选条件 + 根据条件筛选认证申请 + + +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="max-w-sm" + /> + +
+ +
+
+
+ + + + 认证申请列表 + 共 {total} 条申请 + + + + + + 用户 + 身份类型 + 真实姓名 + 学号/工号 + 部门 + 状态 + 提交时间 + 操作 + + + + {loading ? ( + + + +

加载中...

+
+
+ ) : verifications.length === 0 ? ( + + + 暂无数据 + + + ) : ( + verifications.map((verification) => ( + handleViewDetail(verification)} + > + +
+ + + {getInitials(verification.nickname)} + +
+

{verification.nickname}

+

@{verification.username}

+
+
+
+ + {identityText[verification.identity] || verification.identity} + + {verification.real_name} + {verification.student_id || verification.employee_id || '-'} + {verification.department || '-'} + + + {statusText[verification.status]} + + + {formatDate(verification.created_at)} + e.stopPropagation()}> + + +
+ )) + )} +
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+
+ + {/* 详情对话框 */} + + + + 认证详情 + 查看用户认证申请详情 + + {detailLoading ? ( +
+ +
+ ) : selectedVerification ? ( +
+
+ + + {getInitials(selectedVerification.nickname)} + +
+

{selectedVerification.nickname}

+

@{selectedVerification.username}

+
+ + {statusText[selectedVerification.status]} + +
+ +
+
+

身份类型

+

{identityText[selectedVerification.identity] || selectedVerification.identity}

+
+
+

真实姓名

+

{selectedVerification.real_name}

+
+ {selectedVerification.student_id && ( +
+

学号

+

{selectedVerification.student_id}

+
+ )} + {selectedVerification.employee_id && ( +
+

工号

+

{selectedVerification.employee_id}

+
+ )} + {selectedVerification.department && ( +
+

部门/院系

+

{selectedVerification.department}

+
+ )} +
+

证明材料

+
+ 证明材料 +
+
+ {selectedVerification.reviewed_at && ( + <> +
+

审核时间

+

{formatDate(selectedVerification.reviewed_at)}

+
+ {selectedVerification.reviewer_name && ( +
+

审核人

+

{selectedVerification.reviewer_name}

+
+ )} + + )} + {selectedVerification.reject_reason && ( +
+

拒绝原因

+

{selectedVerification.reject_reason}

+
+ )} +
+ + {selectedVerification.status === 'pending' && ( +
+ +
+ )} +
+ ) : ( +

加载失败

+ )} +
+
+ + {/* 审核对话框 */} + + + + 审核认证申请 + 请选择通过或拒绝该认证申请 + +
+
+

拒绝原因(拒绝时必填)

+