Add reports management to Sidebar and API index; introduce new route for reports with appropriate guard
This commit is contained in:
@@ -16,3 +16,5 @@ export { dashboardApi } from './dashboard'
|
|||||||
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
||||||
export { subjectsApi, materialsApi } from './materials'
|
export { subjectsApi, materialsApi } from './materials'
|
||||||
export type { MaterialSubject, MaterialFile, MaterialFileType, SubjectUpsertRequest, MaterialCreateRequest, MaterialUpdateRequest, MaterialQueryParams } 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'
|
||||||
|
|||||||
106
src/api/reports.ts
Normal file
106
src/api/reports.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 管理端举报 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// 举报状态
|
||||||
|
export type ReportStatus = 'pending' | 'processing' | 'resolved' | 'rejected';
|
||||||
|
|
||||||
|
// 举报目标类型
|
||||||
|
export type ReportTargetType = 'post' | 'comment' | 'message';
|
||||||
|
|
||||||
|
// 举报列表项
|
||||||
|
export interface ReportListItem {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
reporter?: {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
target_type: ReportTargetType;
|
||||||
|
target_id: string;
|
||||||
|
reason: string;
|
||||||
|
description?: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
handled_by?: string;
|
||||||
|
handler?: {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
handled_at?: string;
|
||||||
|
result?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 举报详情
|
||||||
|
export interface ReportDetail extends ReportListItem {
|
||||||
|
target_content?: {
|
||||||
|
type: ReportTargetType;
|
||||||
|
id: string;
|
||||||
|
author?: {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
content?: string;
|
||||||
|
title?: string;
|
||||||
|
images?: string[];
|
||||||
|
status?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
export interface ReportQueryParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
target_type?: ReportTargetType;
|
||||||
|
status?: ReportStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理举报请求
|
||||||
|
export interface HandleReportRequest {
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量处理请求
|
||||||
|
export interface BatchHandleRequest {
|
||||||
|
ids: string[];
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量处理响应
|
||||||
|
export interface BatchHandleResponse {
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
failed_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 举报API
|
||||||
|
export const reportsApi = {
|
||||||
|
// 获取举报列表
|
||||||
|
getReports: (params: ReportQueryParams) =>
|
||||||
|
apiClient.get('/admin/reports', { params }),
|
||||||
|
|
||||||
|
// 获取举报详情
|
||||||
|
getReportById: (id: string) =>
|
||||||
|
apiClient.get<ReportDetail>(`/admin/reports/${id}`),
|
||||||
|
|
||||||
|
// 处理举报
|
||||||
|
handleReport: (id: string, data: HandleReportRequest) =>
|
||||||
|
apiClient.put(`/admin/reports/${id}/handle`, data),
|
||||||
|
|
||||||
|
// 批量处理
|
||||||
|
batchHandle: (data: BatchHandleRequest) =>
|
||||||
|
apiClient.post<BatchHandleResponse>('/admin/reports/batch-handle', data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportsApi;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
|
Flag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -68,6 +69,12 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '举报管理',
|
||||||
|
path: '/reports',
|
||||||
|
icon: Flag,
|
||||||
|
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '频道管理',
|
title: '频道管理',
|
||||||
path: '/channels',
|
path: '/channels',
|
||||||
|
|||||||
568
src/pages/Reports.tsx
Normal file
568
src/pages/Reports.tsx
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
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,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import type { PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
// 状态颜色映射
|
||||||
|
const statusColors: Record<ReportStatus, string> = {
|
||||||
|
pending: 'bg-yellow-500',
|
||||||
|
processing: 'bg-blue-500',
|
||||||
|
resolved: 'bg-green-500',
|
||||||
|
rejected: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态文本映射
|
||||||
|
const statusText: Record<ReportStatus, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processing: '处理中',
|
||||||
|
resolved: '已确认',
|
||||||
|
rejected: '已驳回',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标类型图标
|
||||||
|
const targetTypeIcons: Record<ReportTargetType, React.ReactNode> = {
|
||||||
|
post: <FileText className="h-4 w-4" />,
|
||||||
|
comment: <MessageSquare className="h-4 w-4" />,
|
||||||
|
message: <Mail className="h-4 w-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标类型文本
|
||||||
|
const targetTypeText: Record<ReportTargetType, string> = {
|
||||||
|
post: '帖子',
|
||||||
|
comment: '评论',
|
||||||
|
message: '消息',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 举报原因文本
|
||||||
|
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 [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)
|
||||||
|
} 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">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">举报管理</h1>
|
||||||
|
<p className="text-muted-foreground">处理用户举报的帖子、评论和消息</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选卡片 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>筛选条件</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索举报内容..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<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-[140px]">
|
||||||
|
<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}>
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 举报列表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>举报列表</CardTitle>
|
||||||
|
<CardDescription>共 {total} 条记录</CardDescription>
|
||||||
|
</div>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBatchAction('approve')}
|
||||||
|
disabled={batchLoading}
|
||||||
|
>
|
||||||
|
{batchLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
|
||||||
|
批量确认 ({selectedIds.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBatchAction('reject')}
|
||||||
|
disabled={batchLoading}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
批量驳回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>举报人</TableHead>
|
||||||
|
<TableHead>目标类型</TableHead>
|
||||||
|
<TableHead>举报原因</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>举报时间</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
<span className="text-muted-foreground mt-2 block">加载中...</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : reports.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
暂无举报记录
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
reports.map((report) => (
|
||||||
|
<TableRow key={report.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(report.id)}
|
||||||
|
onCheckedChange={(checked) => handleSelect(report.id, !!checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={report.reporter?.avatar} />
|
||||||
|
<AvatarFallback>{report.reporter?.nickname?.[0] || '?'}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{report.reporter?.nickname || '未知用户'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{targetTypeIcons[report.target_type]}
|
||||||
|
<span>{targetTypeText[report.target_type]}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span>{reasonText[report.reason] || report.reason}</span>
|
||||||
|
{report.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{report.description}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={statusColors[report.status]}>
|
||||||
|
{statusText[report.status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(report.created_at), 'yyyy-MM-dd HH:mm')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleViewDetail(report)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{report.status === 'pending' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openHandleDialog(report)}>
|
||||||
|
<Flag className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<PaginationNavigator
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={Math.ceil(total / pageSize)}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 详情对话框 */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>举报详情</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : selectedReport ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">举报人</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={selectedReport.reporter?.avatar} />
|
||||||
|
<AvatarFallback>{selectedReport.reporter?.nickname?.[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{selectedReport.reporter?.nickname}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">举报时间</Label>
|
||||||
|
<p>{format(new Date(selectedReport.created_at), 'yyyy-MM-dd HH:mm:ss')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">目标类型</Label>
|
||||||
|
<p>{targetTypeText[selectedReport.target_type]}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">处理状态</Label>
|
||||||
|
<Badge className={statusColors[selectedReport.status]}>
|
||||||
|
{statusText[selectedReport.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-muted-foreground">举报原因</Label>
|
||||||
|
<p>{reasonText[selectedReport.reason] || selectedReport.reason}</p>
|
||||||
|
</div>
|
||||||
|
{selectedReport.description && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-muted-foreground">详细描述</Label>
|
||||||
|
<p>{selectedReport.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedReport.target_content && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">被举报内容</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedReport.target_content.title && (
|
||||||
|
<p className="font-medium mb-2">{selectedReport.target_content.title}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedReport.target_content.content}</p>
|
||||||
|
{selectedReport.target_content.images && selectedReport.target_content.images.length > 0 && (
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
{selectedReport.target_content.images.map((img, i) => (
|
||||||
|
<img key={i} src={img} alt="" className="w-16 h-16 object-cover rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedReport.handler && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">处理人</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={selectedReport.handler.avatar} />
|
||||||
|
<AvatarFallback>{selectedReport.handler.nickname?.[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{selectedReport.handler.nickname}</span>
|
||||||
|
{selectedReport.handled_at && (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{format(new Date(selectedReport.handled_at), 'yyyy-MM-dd HH:mm')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedReport.result && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">处理结果</Label>
|
||||||
|
<p>{selectedReport.result}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 处理对话框 */}
|
||||||
|
<Dialog open={handleOpen} onOpenChange={setHandleOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>处理举报</DialogTitle>
|
||||||
|
<DialogDescription>请选择处理方式</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant={handleAction === 'approve' ? 'default' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHandleAction('approve')}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
确认违规
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={handleAction === 'reject' ? 'default' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHandleAction('reject')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
驳回举报
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>处理说明(选填)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={handleResult}
|
||||||
|
onChange={(e) => setHandleResult(e.target.value)}
|
||||||
|
placeholder="请输入处理说明..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setHandleOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={submitHandle} disabled={handleLoading}>
|
||||||
|
{handleLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
确认提交
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,6 +104,7 @@ const Posts = lazy(() => import('@/pages/Posts'))
|
|||||||
const Comments = lazy(() => import('@/pages/Comments'))
|
const Comments = lazy(() => import('@/pages/Comments'))
|
||||||
const Groups = lazy(() => import('@/pages/Groups'))
|
const Groups = lazy(() => import('@/pages/Groups'))
|
||||||
const Channels = lazy(() => import('@/pages/Channels'))
|
const Channels = lazy(() => import('@/pages/Channels'))
|
||||||
|
const Reports = lazy(() => import('@/pages/Reports'))
|
||||||
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
||||||
const Materials = lazy(() => import('@/pages/Materials'))
|
const Materials = lazy(() => import('@/pages/Materials'))
|
||||||
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
||||||
@@ -194,6 +195,16 @@ export const router = createBrowserRouter([
|
|||||||
</ModeratorGuard>
|
</ModeratorGuard>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
element: (
|
||||||
|
<ModeratorGuard>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Reports />
|
||||||
|
</Suspense>
|
||||||
|
</ModeratorGuard>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'channels',
|
path: 'channels',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user