826 lines
34 KiB
TypeScript
826 lines
34 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import { reportsApi, type ReportListItem, type ReportDetail, type ReportQueryParams, type ReportStatus, type ReportTargetType } 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 { Checkbox } from '@/components/ui/checkbox'
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||
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 { Textarea } from '@/components/ui/textarea'
|
||
import { Label } from '@/components/ui/label'
|
||
import {
|
||
Search,
|
||
RefreshCw,
|
||
Eye,
|
||
Check,
|
||
X,
|
||
Loader2,
|
||
Flag,
|
||
FileText,
|
||
MessageSquare,
|
||
Mail,
|
||
AlertTriangle,
|
||
Clock,
|
||
CheckCircle2,
|
||
XCircle,
|
||
Filter,
|
||
ChevronRight,
|
||
} from 'lucide-react'
|
||
import { format } from 'date-fns'
|
||
import type { PaginatedResponse } from '@/types'
|
||
|
||
// 安全的日期格式化函数,处理无效日期
|
||
function safeFormat(dateValue: string | number | Date | null | undefined, formatStr: string): string {
|
||
if (!dateValue) return '-'
|
||
const date = new Date(dateValue)
|
||
if (isNaN(date.getTime())) return '-'
|
||
return format(date, formatStr)
|
||
}
|
||
|
||
// 状态颜色映射
|
||
const statusColors: Record<ReportStatus, string> = {
|
||
pending: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||
processing: 'bg-blue-100 text-blue-800 border-blue-200',
|
||
resolved: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||
rejected: 'bg-gray-100 text-gray-600 border-gray-200',
|
||
}
|
||
|
||
// 状态文本映射
|
||
const statusText: Record<ReportStatus, string> = {
|
||
pending: '待处理',
|
||
processing: '处理中',
|
||
resolved: '已确认',
|
||
rejected: '已驳回',
|
||
}
|
||
|
||
// 状态图标映射
|
||
const statusIcons: Record<ReportStatus, React.ReactNode> = {
|
||
pending: <Clock className="h-3 w-3" />,
|
||
processing: <AlertTriangle className="h-3 w-3" />,
|
||
resolved: <CheckCircle2 className="h-3 w-3" />,
|
||
rejected: <XCircle className="h-3 w-3" />,
|
||
}
|
||
|
||
// 举报原因图标和颜色
|
||
const reasonConfig: Record<string, { icon: React.ReactNode; color: string }> = {
|
||
spam: { icon: <Mail className="h-3 w-3" />, color: 'bg-purple-100 text-purple-700' },
|
||
inappropriate: { icon: <AlertTriangle className="h-3 w-3" />, color: 'bg-red-100 text-red-700' },
|
||
harassment: { icon: <MessageSquare className="h-3 w-3" />, color: 'bg-orange-100 text-orange-700' },
|
||
misinformation: { icon: <FileText className="h-3 w-3" />, color: 'bg-amber-100 text-amber-700' },
|
||
other: { icon: <Flag className="h-3 w-3" />, color: 'bg-gray-100 text-gray-600' },
|
||
}
|
||
|
||
// 目标类型配置
|
||
const targetTypeConfig: Record<ReportTargetType, { icon: React.ReactNode; color: string; label: string }> = {
|
||
post: { icon: <FileText className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50', label: '帖子' },
|
||
comment: { icon: <MessageSquare className="h-4 w-4" />, color: 'text-green-600 bg-green-50', label: '评论' },
|
||
message: { icon: <Mail className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50', label: '消息' },
|
||
}
|
||
|
||
// 举报原因文本
|
||
const reasonText: Record<string, string> = {
|
||
spam: '垃圾广告',
|
||
inappropriate: '违规内容',
|
||
harassment: '辱骂/攻击',
|
||
misinformation: '虚假信息',
|
||
other: '其他',
|
||
}
|
||
|
||
export default function Reports() {
|
||
// 状态
|
||
const [reports, setReports] = useState<ReportListItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize] = useState(10)
|
||
const [keyword, setKeyword] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||
|
||
// 统计数据
|
||
const [stats, setStats] = useState({
|
||
pending: 0,
|
||
processing: 0,
|
||
resolved: 0,
|
||
rejected: 0,
|
||
})
|
||
|
||
// 批量选择
|
||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||
const [isAllSelected, setIsAllSelected] = useState(false)
|
||
|
||
// 详情对话框
|
||
const [detailOpen, setDetailOpen] = useState(false)
|
||
const [selectedReport, setSelectedReport] = useState<ReportDetail | null>(null)
|
||
const [detailLoading, setDetailLoading] = useState(false)
|
||
|
||
// 处理对话框
|
||
const [handleOpen, setHandleOpen] = useState(false)
|
||
const [handleReport, setHandleReport] = useState<ReportListItem | null>(null)
|
||
const [handleAction, setHandleAction] = useState<'approve' | 'reject'>('approve')
|
||
const [handleResult, setHandleResult] = useState('')
|
||
const [handleLoading, setHandleLoading] = useState(false)
|
||
|
||
// 批量操作加载状态
|
||
const [batchLoading, setBatchLoading] = useState(false)
|
||
|
||
// 加载举报列表
|
||
const loadReports = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const params: ReportQueryParams = {
|
||
page,
|
||
page_size: pageSize,
|
||
keyword: keyword || undefined,
|
||
status: statusFilter !== 'all' ? (statusFilter as ReportStatus) : undefined,
|
||
target_type: typeFilter !== 'all' ? (typeFilter as ReportTargetType) : undefined,
|
||
}
|
||
const response = await reportsApi.getReports(params)
|
||
const data = response.data.data as PaginatedResponse<ReportListItem>
|
||
setReports(data.list || [])
|
||
setTotal(data.total || 0)
|
||
|
||
// 更新统计数据(仅当无筛选时)
|
||
if (!keyword && statusFilter === 'all' && typeFilter === 'all') {
|
||
const newStats = { pending: 0, processing: 0, resolved: 0, rejected: 0 }
|
||
data.list?.forEach((report: ReportListItem) => {
|
||
newStats[report.status] = (newStats[report.status] || 0) + 1
|
||
})
|
||
setStats(newStats)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load reports:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
setSelectedIds([])
|
||
setIsAllSelected(false)
|
||
}
|
||
}, [page, pageSize, keyword, statusFilter, typeFilter])
|
||
|
||
useEffect(() => {
|
||
loadReports()
|
||
}, [loadReports])
|
||
|
||
// 处理搜索
|
||
const handleSearch = () => {
|
||
setPage(1)
|
||
loadReports()
|
||
}
|
||
|
||
// 处理刷新
|
||
const handleRefresh = () => {
|
||
loadReports()
|
||
}
|
||
|
||
// 全选/取消全选
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedIds(reports.map(r => r.id))
|
||
setIsAllSelected(true)
|
||
} else {
|
||
setSelectedIds([])
|
||
setIsAllSelected(false)
|
||
}
|
||
}
|
||
|
||
// 单选
|
||
const handleSelect = (id: string, checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedIds([...selectedIds, id])
|
||
} else {
|
||
setSelectedIds(selectedIds.filter(i => i !== id))
|
||
}
|
||
setIsAllSelected(checked && selectedIds.length === reports.length - 1)
|
||
}
|
||
|
||
// 查看详情
|
||
const handleViewDetail = async (report: ReportListItem) => {
|
||
setDetailLoading(true)
|
||
setDetailOpen(true)
|
||
try {
|
||
const response = await reportsApi.getReportById(report.id)
|
||
setSelectedReport(response.data)
|
||
} catch (error) {
|
||
console.error('Failed to load report detail:', error)
|
||
setDetailOpen(false)
|
||
} finally {
|
||
setDetailLoading(false)
|
||
}
|
||
}
|
||
|
||
// 打开处理对话框
|
||
const openHandleDialog = (report: ReportListItem) => {
|
||
setHandleReport(report)
|
||
setHandleAction('approve')
|
||
setHandleResult('')
|
||
setHandleOpen(true)
|
||
}
|
||
|
||
// 提交处理
|
||
const submitHandle = async () => {
|
||
if (!handleReport) return
|
||
setHandleLoading(true)
|
||
try {
|
||
await reportsApi.handleReport(handleReport.id, {
|
||
action: handleAction,
|
||
result: handleResult || undefined,
|
||
})
|
||
setHandleOpen(false)
|
||
loadReports()
|
||
} catch (error) {
|
||
console.error('Failed to handle report:', error)
|
||
} finally {
|
||
setHandleLoading(false)
|
||
}
|
||
}
|
||
|
||
// 批量处理
|
||
const handleBatchAction = async (action: 'approve' | 'reject') => {
|
||
if (selectedIds.length === 0) return
|
||
setBatchLoading(true)
|
||
try {
|
||
await reportsApi.batchHandle({
|
||
ids: selectedIds,
|
||
action,
|
||
})
|
||
loadReports()
|
||
} catch (error) {
|
||
console.error('Failed to batch handle:', error)
|
||
} finally {
|
||
setBatchLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 pb-8">
|
||
{/* 页面标题 */}
|
||
<div className="flex items-center justify-between bg-gradient-to-r from-slate-50 to-white p-6 rounded-2xl border border-slate-200/60 shadow-sm">
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-red-100 rounded-xl">
|
||
<Flag className="h-6 w-6 text-red-600" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900">举报管理</h1>
|
||
<p className="text-sm text-slate-500 mt-0.5">处理用户举报的帖子、评论和消息,维护社区环境</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
className="bg-white hover:bg-slate-50 border-slate-200"
|
||
>
|
||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<Card className="border-l-4 border-l-yellow-400 hover:shadow-md transition-shadow">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-500">待处理</p>
|
||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.pending}</p>
|
||
</div>
|
||
<div className="p-2 bg-yellow-100 rounded-lg">
|
||
<Clock className="h-5 w-5 text-yellow-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-l-4 border-l-blue-400 hover:shadow-md transition-shadow">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-500">处理中</p>
|
||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.processing}</p>
|
||
</div>
|
||
<div className="p-2 bg-blue-100 rounded-lg">
|
||
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-l-4 border-l-emerald-400 hover:shadow-md transition-shadow">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-500">已确认</p>
|
||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.resolved}</p>
|
||
</div>
|
||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-l-4 border-l-gray-400 hover:shadow-md transition-shadow">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-500">已驳回</p>
|
||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.rejected}</p>
|
||
</div>
|
||
<div className="p-2 bg-gray-100 rounded-lg">
|
||
<XCircle className="h-5 w-5 text-gray-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 筛选卡片 */}
|
||
<Card className="border-slate-200/60 shadow-sm">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||
<Filter className="h-4 w-4" />
|
||
筛选条件
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-wrap gap-3">
|
||
<div className="flex-1 min-w-[240px]">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||
<Input
|
||
placeholder="搜索举报内容、举报人..."
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||
<SelectTrigger className="w-[130px]">
|
||
<SelectValue placeholder="目标类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部类型</SelectItem>
|
||
<SelectItem value="post">帖子</SelectItem>
|
||
<SelectItem value="comment">评论</SelectItem>
|
||
<SelectItem value="message">消息</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="w-[130px]">
|
||
<SelectValue placeholder="处理状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部状态</SelectItem>
|
||
<SelectItem value="pending">待处理</SelectItem>
|
||
<SelectItem value="processing">处理中</SelectItem>
|
||
<SelectItem value="resolved">已确认</SelectItem>
|
||
<SelectItem value="rejected">已驳回</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button onClick={handleSearch} className="bg-slate-900 hover:bg-slate-800">
|
||
<Search className="h-4 w-4 mr-2" />
|
||
搜索
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 举报列表 */}
|
||
<Card className="border-slate-200/60 shadow-sm">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||
<div>
|
||
<CardTitle className="text-base font-semibold">举报列表</CardTitle>
|
||
<CardDescription className="text-sm mt-1">
|
||
共 <span className="font-semibold text-slate-900">{total}</span> 条记录
|
||
{selectedIds.length > 0 && (
|
||
<span className="ml-2 text-blue-600 font-medium">
|
||
· 已选择 {selectedIds.length} 条
|
||
</span>
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
{selectedIds.length > 0 && (
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleBatchAction('approve')}
|
||
disabled={batchLoading}
|
||
className="border-emerald-200 text-emerald-700 hover:bg-emerald-50"
|
||
>
|
||
{batchLoading ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Check className="h-4 w-4 mr-2" />
|
||
)}
|
||
批量确认
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleBatchAction('reject')}
|
||
disabled={batchLoading}
|
||
className="border-red-200 text-red-700 hover:bg-red-50"
|
||
>
|
||
<X className="h-4 w-4 mr-2" />
|
||
批量驳回
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="rounded-lg border border-slate-200">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-slate-50/50 hover:bg-slate-50">
|
||
<TableHead className="w-10">
|
||
<Checkbox
|
||
checked={isAllSelected}
|
||
onCheckedChange={handleSelectAll}
|
||
className="data-[state=checked]:bg-slate-900 data-[state=checked]:border-slate-900"
|
||
/>
|
||
</TableHead>
|
||
<TableHead className="font-semibold text-slate-700">举报人</TableHead>
|
||
<TableHead className="font-semibold text-slate-700">目标类型</TableHead>
|
||
<TableHead className="font-semibold text-slate-700">举报原因</TableHead>
|
||
<TableHead className="font-semibold text-slate-700">状态</TableHead>
|
||
<TableHead className="font-semibold text-slate-700">举报时间</TableHead>
|
||
<TableHead className="font-semibold text-slate-700 text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center py-12">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||
<span className="text-slate-500">加载中...</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : reports.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center py-12">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div className="p-3 bg-slate-100 rounded-full">
|
||
<Flag className="h-6 w-6 text-slate-400" />
|
||
</div>
|
||
<span className="text-slate-500 font-medium">暂无举报记录</span>
|
||
<span className="text-slate-400 text-sm">所有举报都已处理完毕</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
reports.map((report) => {
|
||
const reasonCfg = reasonConfig[report.reason] || reasonConfig.other
|
||
const typeCfg = targetTypeConfig[report.target_type] || {
|
||
icon: <FileText className="h-4 w-4" />,
|
||
color: 'text-slate-600 bg-slate-50',
|
||
label: report.target_type || '未知',
|
||
}
|
||
return (
|
||
<TableRow
|
||
key={report.id}
|
||
className={`group transition-colors ${
|
||
selectedIds.includes(report.id) ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'
|
||
}`}
|
||
>
|
||
<TableCell>
|
||
<Checkbox
|
||
checked={selectedIds.includes(report.id)}
|
||
onCheckedChange={(checked) => handleSelect(report.id, !!checked)}
|
||
className="data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2.5">
|
||
<Avatar className="h-9 w-9 ring-2 ring-white">
|
||
<AvatarImage src={report.reporter?.avatar} />
|
||
<AvatarFallback className="bg-slate-100 text-slate-600 text-sm font-medium">
|
||
{report.reporter?.nickname?.[0] || '?'}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<span className="font-medium text-slate-900">
|
||
{report.reporter?.nickname || '未知用户'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className={`flex items-center gap-2 px-2.5 py-1 rounded-md w-fit ${typeCfg.color}`}>
|
||
{typeCfg.icon}
|
||
<span className="text-sm font-medium">{typeCfg.label}</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex flex-col gap-1">
|
||
<div className={`flex items-center gap-2 w-fit px-2 py-0.5 rounded text-sm font-medium ${reasonCfg.color}`}>
|
||
{reasonCfg.icon}
|
||
<span>{reasonText[report.reason] || report.reason}</span>
|
||
</div>
|
||
{report.description && (
|
||
<p className="text-xs text-slate-500 line-clamp-1 max-w-[200px]">
|
||
{report.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
className={`flex items-center gap-1.5 px-2.5 py-1 border ${statusColors[report.status]}`}
|
||
>
|
||
{statusIcons[report.status]}
|
||
<span className="font-medium">{statusText[report.status]}</span>
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-sm text-slate-600 font-mono">
|
||
{safeFormat(report.created_at, 'yyyy-MM-dd HH:mm')}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center justify-end gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleViewDetail(report)}
|
||
className="h-8 w-8 p-0 hover:bg-slate-100"
|
||
>
|
||
<Eye className="h-4 w-4 text-slate-600" />
|
||
</Button>
|
||
{report.status === 'pending' && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => openHandleDialog(report)}
|
||
className="h-8 w-8 p-0 hover:bg-yellow-50"
|
||
>
|
||
<Flag className="h-4 w-4 text-yellow-600" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-slate-100"
|
||
>
|
||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<div className="mt-4 flex items-center justify-between">
|
||
<div className="text-sm text-slate-500">
|
||
显示第 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条
|
||
</div>
|
||
<PaginationNavigator
|
||
currentPage={page}
|
||
totalPages={Math.ceil(total / pageSize)}
|
||
onPageChange={setPage}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 详情对话框 */}
|
||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Flag className="h-5 w-5 text-red-500" />
|
||
举报详情
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{detailLoading ? (
|
||
<div className="flex justify-center py-12">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||
<span className="text-slate-500">加载中...</span>
|
||
</div>
|
||
</div>
|
||
) : selectedReport ? (
|
||
<div className="space-y-5">
|
||
{/* 基本信息 */}
|
||
<div className="p-4 bg-slate-50 rounded-xl">
|
||
<h4 className="text-sm font-semibold text-slate-700 mb-3">基本信息</h4>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label className="text-xs text-slate-500">举报人</Label>
|
||
<div className="flex items-center gap-2 mt-1.5">
|
||
<Avatar className="h-7 w-7">
|
||
<AvatarImage src={selectedReport.reporter?.avatar} />
|
||
<AvatarFallback className="text-xs">{selectedReport.reporter?.nickname?.[0]}</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-sm font-medium text-slate-900">
|
||
{selectedReport.reporter?.nickname}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-slate-500">举报时间</Label>
|
||
<p className="text-sm font-mono text-slate-700 mt-1.5">
|
||
{safeFormat(selectedReport.created_at, 'yyyy-MM-dd HH:mm:ss')}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-slate-500">目标类型</Label>
|
||
{(() => {
|
||
const detailTypeCfg = targetTypeConfig[selectedReport.target_type] || {
|
||
icon: <FileText className="h-4 w-4" />,
|
||
color: 'text-slate-600 bg-slate-50',
|
||
label: selectedReport.target_type || '未知',
|
||
}
|
||
return (
|
||
<div className={`flex items-center gap-2 mt-1.5 w-fit px-2 py-0.5 rounded ${detailTypeCfg.color}`}>
|
||
{detailTypeCfg.icon}
|
||
<span className="text-sm font-medium">{detailTypeCfg.label}</span>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-slate-500">处理状态</Label>
|
||
<div className="mt-1.5">
|
||
<Badge className={`flex items-center gap-1.5 ${statusColors[selectedReport.status]}`}>
|
||
{statusIcons[selectedReport.status]}
|
||
<span className="font-medium">{statusText[selectedReport.status]}</span>
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 举报原因 */}
|
||
<div>
|
||
<Label className="text-xs text-slate-500">举报原因</Label>
|
||
<div className={`flex items-center gap-2 mt-2 w-fit px-3 py-1.5 rounded-lg ${reasonConfig[selectedReport.reason]?.color || reasonConfig.other.color}`}>
|
||
{reasonConfig[selectedReport.reason]?.icon || reasonConfig.other.icon}
|
||
<span className="text-sm font-medium">
|
||
{reasonText[selectedReport.reason] || selectedReport.reason}
|
||
</span>
|
||
</div>
|
||
{selectedReport.description && (
|
||
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||
<Label className="text-xs text-amber-700">详细描述</Label>
|
||
<p className="text-sm text-amber-900 mt-1">{selectedReport.description}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 被举报内容 */}
|
||
{selectedReport.target_content && (
|
||
<div className="p-4 bg-slate-50 rounded-xl">
|
||
<h4 className="text-sm font-semibold text-slate-700 mb-3">被举报内容</h4>
|
||
{selectedReport.target_content.title && (
|
||
<p className="font-semibold text-slate-900 mb-2">{selectedReport.target_content.title}</p>
|
||
)}
|
||
<p className="text-sm text-slate-600 leading-relaxed">{selectedReport.target_content.content}</p>
|
||
{selectedReport.target_content.images && selectedReport.target_content.images.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mt-3">
|
||
{selectedReport.target_content.images.map((img, i) => (
|
||
<img
|
||
key={i}
|
||
src={img}
|
||
alt=""
|
||
className="w-20 h-20 object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity ring-1 ring-slate-200"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 处理信息 */}
|
||
{selectedReport.handler && (
|
||
<div className="p-4 bg-slate-50 rounded-xl">
|
||
<h4 className="text-sm font-semibold text-slate-700 mb-3">处理信息</h4>
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-8 w-8">
|
||
<AvatarImage src={selectedReport.handler.avatar} />
|
||
<AvatarFallback className="text-xs">{selectedReport.handler.nickname?.[0]}</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-900">{selectedReport.handler.nickname}</p>
|
||
{selectedReport.handled_at && (
|
||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||
{safeFormat(selectedReport.handled_at, 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{selectedReport.result && (
|
||
<div className="mt-3 p-3 bg-white border border-slate-200 rounded-lg">
|
||
<Label className="text-xs text-slate-500">处理结果</Label>
|
||
<p className="text-sm text-slate-700 mt-1">{selectedReport.result}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 处理对话框 */}
|
||
<Dialog open={handleOpen} onOpenChange={setHandleOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Flag className="h-5 w-5 text-yellow-500" />
|
||
处理举报
|
||
</DialogTitle>
|
||
<DialogDescription className="text-sm">
|
||
请根据举报内容选择处理方式
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Button
|
||
variant={handleAction === 'approve' ? 'default' : 'outline'}
|
||
className={`h-20 flex-col gap-2 ${
|
||
handleAction === 'approve'
|
||
? 'bg-red-600 hover:bg-red-700'
|
||
: 'border-slate-200 hover:bg-red-50 hover:text-red-600 hover:border-red-200'
|
||
}`}
|
||
onClick={() => setHandleAction('approve')}
|
||
>
|
||
<Check className="h-5 w-5" />
|
||
<span>确认违规</span>
|
||
</Button>
|
||
<Button
|
||
variant={handleAction === 'reject' ? 'default' : 'outline'}
|
||
className={`h-20 flex-col gap-2 ${
|
||
handleAction === 'reject'
|
||
? 'bg-emerald-600 hover:bg-emerald-700'
|
||
: 'border-slate-200 hover:bg-emerald-50 hover:text-emerald-600 hover:border-emerald-200'
|
||
}`}
|
||
onClick={() => setHandleAction('reject')}
|
||
>
|
||
<X className="h-5 w-5" />
|
||
<span>驳回举报</span>
|
||
</Button>
|
||
</div>
|
||
<div>
|
||
<Label className="text-sm font-medium">处理说明(选填)</Label>
|
||
<Textarea
|
||
value={handleResult}
|
||
onChange={(e) => setHandleResult(e.target.value)}
|
||
placeholder="请输入处理说明,将发送给举报人..."
|
||
rows={3}
|
||
className="mt-2 resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button variant="outline" onClick={() => setHandleOpen(false)} className="border-slate-200">
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={submitHandle}
|
||
disabled={handleLoading}
|
||
className={handleAction === 'approve' ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'}
|
||
>
|
||
{handleLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||
确认提交
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
} |