feat(moderation): resource-centric reported comments/events pages

Rework 内容审核 pages around reported resources instead of individual
reports, matching the new backend endpoints: rows group all reports on
one comment/event, with a slide-over listing report details and
per-row actions 通过并屏蔽 (approve) / 驳回举报 (reject) / 撤销屏蔽
(revert). Filters: 待处理 / 已屏蔽 / 全部.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:20:37 +08:00
parent 999d5353fd
commit f5a7249969
9 changed files with 532 additions and 381 deletions

View File

@ -2,15 +2,16 @@ import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { CommentReport } from '@/api/types'
import type { CommentReportAction } from '@/api/modules/comments'
import { fetchCommentReports, handleCommentReport } from '@/api/modules/comments'
import type { ReportedComment } from '@/api/types'
import type { ModerationAction } from '@/api/modules/comments'
import { fetchReportedComments, moderateReportedComment } from '@/api/modules/comments'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ReportStatusBadge } from '@/features/reports/report-status-badge'
import { ResourceStateBadge } from '@/features/reports/report-status-badge'
import { ReportsSheet } from '@/features/reports/reports-sheet'
import { Button } from '@/components/ui/button'
import {
Select,
@ -21,20 +22,21 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/comment-reports')({
component: CommentReportsPage,
component: ReportedCommentsPage,
})
function CommentReportsPage() {
const [status, setStatus] = useState<string>('open')
function ReportedCommentsPage() {
const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedComment | null>(null)
const list = useListPage(
'comment-reports',
fetchCommentReports,
'reported-comments',
fetchReportedComments,
status === 'all' ? {} : { status },
)
const act = async (row: CommentReport, action: CommentReportAction, done: string) => {
const act = async (row: ReportedComment, action: ModerationAction, done: string) => {
try {
await handleCommentReport(row.id, action)
await moderateReportedComment(row.id, action)
toast.success(done)
void list.query.refetch()
} catch (error) {
@ -42,111 +44,108 @@ function CommentReportsPage() {
}
}
const columns: ColumnDef<CommentReport>[] = [
const columns: ColumnDef<ReportedComment>[] = [
{
header: '举报原因',
header: '评论',
cell: ({ row }) => (
<div className="max-w-56">
<div className="font-medium">{row.original.reason}</div>
{row.original.message ? (
<div className="truncate text-xs text-muted-foreground" title={row.original.message}>
{row.original.message}
</div>
) : null}
</div>
),
},
{
header: '被举报评论',
cell: ({ row }) => (
<div className="max-w-72">
<div className="truncate" title={row.original.comment?.body ?? undefined}>
{row.original.comment?.body ?? `#${row.original.comment_id}(已不存在)`}
<div className="max-w-80">
<div className="truncate" title={row.original.body ?? undefined}>
{row.original.body ?? '—'}
</div>
<div className="text-xs text-muted-foreground">
<code className="font-mono">#{row.original.id}</code>
<span className="ml-2">{row.original.author?.display_name ?? '—'}</span>
</div>
<code className="font-mono text-xs text-muted-foreground">#{row.original.comment_id}</code>
</div>
),
},
{
header: '举报',
header: '举报',
cell: ({ row }) => (
<div>
<div className="text-sm">{row.original.reporter?.display_name ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
{row.original.reporter?.uid ?? ''}
</code>
</div>
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
{row.original.reports.length}
{row.original.open_reports_count > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{row.original.open_reports_count}
</span>
)}
</Button>
),
},
{
header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />,
cell: ({ row }) => (
<ResourceStateBadge
isBlocked={row.original.is_blocked}
openReports={row.original.open_reports_count}
/>
),
},
{
header: '举报时间',
header: '最近举报',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—',
row.original.latest_report_at
? new Date(row.original.latest_report_at).toLocaleString('zh-CN')
: '—',
},
{
header: '操作',
cell: ({ row }) =>
row.original.status === 'open' ? (
<div className="flex items-center gap-1.5">
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`隐藏评论 #${row.original.comment_id}`}
description="评论将对用户不可见,举报标记为已处理。"
onConfirm={() => act(row.original, 'hide', '已隐藏评论')}
/>
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title={`删除评论 #${row.original.comment_id}`}
description="评论将被删除,举报标记为已处理。"
confirmLabel="删除"
destructive
onConfirm={() => act(row.original, 'delete', '已删除评论')}
title={`撤销屏蔽评论 #${row.original.id}`}
description="评论将重新对用户可见,举报记录保留。"
onConfirm={() => act(row.original, 'revert', '已撤销屏蔽')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="ghost">
</Button>
}
title="驳回该举报?"
description="评论保持原样,举报标记为已驳回。"
confirmLabel="驳回"
onConfirm={() => act(row.original, 'dismiss', '已驳回举报')}
/>
</div>
) : (
<span className="text-xs text-muted-foreground">
{row.original.handled_at
? new Date(row.original.handled_at).toLocaleString('zh-CN')
: '—'}
</span>
),
) : (
row.original.open_reports_count > 0 && (
<>
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`屏蔽评论 #${row.original.id}`}
description="举报成立:评论将对用户不可见(可撤销),其全部待处理举报标记为已处理。"
destructive
onConfirm={() => act(row.original, 'approve', '已屏蔽评论')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title="驳回该评论的全部待处理举报?"
description="举报不成立:评论保持原样。"
confirmLabel="驳回"
onConfirm={() => act(row.original, 'reject', '已驳回举报')}
/>
</>
)
)}
</div>
),
},
]
return (
<div>
<PageHeader
title="评论举报"
description="用户对评论的举报,可隐藏、删除评论或驳回举报"
title="被举报评论"
description="按评论聚合的举报,一次决定处理该评论的全部待处理举报"
actions={
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="open"></SelectItem>
<SelectItem value="resolved"></SelectItem>
<SelectItem value="dismissed"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="blocked"></SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
}
@ -156,10 +155,17 @@ function CommentReportsPage() {
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有相关举报"
emptyText="没有被举报的评论"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<ReportsSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={`评论 #${detail?.id ?? ''} 的举报`}
description={detail?.body ?? undefined}
reports={detail?.reports ?? []}
/>
</div>
)
}

View File

@ -2,15 +2,17 @@ import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { FileXEventReport } from '@/api/types'
import type { EventReportAction } from '@/api/modules/filex'
import { fetchEventReports, handleEventReport } from '@/api/modules/filex'
import type { ReportedEvent } from '@/api/types'
import type { ModerationAction } from '@/api/modules/comments'
import { fetchReportedEvents, moderateReportedEvent } from '@/api/modules/filex'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ReportStatusBadge } from '@/features/reports/report-status-badge'
import { ResourceStateBadge } from '@/features/reports/report-status-badge'
import { ReportsSheet } from '@/features/reports/reports-sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
@ -21,20 +23,21 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/event-reports')({
component: EventReportsPage,
component: ReportedEventsPage,
})
function EventReportsPage() {
const [status, setStatus] = useState<string>('open')
function ReportedEventsPage() {
const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedEvent | null>(null)
const list = useListPage(
'event-reports',
fetchEventReports,
'reported-events',
fetchReportedEvents,
status === 'all' ? {} : { status },
)
const act = async (row: FileXEventReport, action: EventReportAction, done: string) => {
const act = async (row: ReportedEvent, action: ModerationAction, done: string) => {
try {
await handleEventReport(row.id, action)
await moderateReportedEvent(row.id, action)
toast.success(done)
void list.query.refetch()
} catch (error) {
@ -42,121 +45,112 @@ function EventReportsPage() {
}
}
const columns: ColumnDef<FileXEventReport>[] = [
const columns: ColumnDef<ReportedEvent>[] = [
{
header: '举报原因',
header: '事件',
cell: ({ row }) => (
<div className="max-w-56">
<div className="font-medium">{row.original.reason}</div>
{row.original.message ? (
<div className="truncate text-xs text-muted-foreground" title={row.original.message}>
{row.original.message}
</div>
) : null}
</div>
),
},
{
header: '被举报事件',
cell: ({ row }) => (
<div className="max-w-72">
<div className="truncate" title={row.original.event?.title ?? undefined}>
{row.original.event?.title ?? `#${row.original.event_id}(已删除)`}
<div className="max-w-80">
<div className="truncate font-medium" title={row.original.title ?? undefined}>
{row.original.title ?? '—'}
</div>
<div className="text-xs text-muted-foreground">
<code className="font-mono">#{row.original.id}</code>
<span className="ml-2">{row.original.author?.display_name ?? '—'}</span>
</div>
<code className="font-mono text-xs text-muted-foreground">#{row.original.event_id}</code>
</div>
),
},
{
header: '举报人',
header: '可见性',
cell: ({ row }) => <Badge variant="secondary">{row.original.visibility ?? '—'}</Badge>,
},
{
header: '举报',
cell: ({ row }) => (
<div>
<div className="text-sm">{row.original.reporter?.display_name ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
{row.original.reporter?.uid ?? ''}
</code>
</div>
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
{row.original.reports.length}
{row.original.open_reports_count > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{row.original.open_reports_count}
</span>
)}
</Button>
),
},
{
header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />,
cell: ({ row }) => (
<ResourceStateBadge
isBlocked={row.original.is_blocked}
openReports={row.original.open_reports_count}
/>
),
},
{
header: '举报时间',
header: '最近举报',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—',
row.original.latest_report_at
? new Date(row.original.latest_report_at).toLocaleString('zh-CN')
: '—',
},
{
header: '操作',
cell: ({ row }) =>
row.original.status === 'open' ? (
<div className="flex items-center gap-1.5">
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`隐藏事件 #${row.original.event_id}`}
description="事件将对用户不可见,举报标记为已处理。"
onConfirm={() => act(row.original, 'hide', '已隐藏事件')}
/>
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title={`删除事件 #${row.original.event_id}`}
description="事件将被删除,举报标记为已处理。"
confirmLabel="删除"
destructive
onConfirm={() => act(row.original, 'delete', '已删除事件')}
title={`撤销屏蔽事件「${row.original.title ?? row.original.id}`}
description="事件将重新对用户可见,举报记录保留。"
onConfirm={() => act(row.original, 'revert', '已撤销屏蔽')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="ghost">
</Button>
}
title="标记该举报为已处理"
description="事件保持原样,仅将举报标记为已处理。"
onConfirm={() => act(row.original, 'resolve', '已标记处理')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="ghost">
</Button>
}
title="驳回该举报?"
description="事件保持原样,举报标记为已驳回。"
confirmLabel="驳回"
onConfirm={() => act(row.original, 'dismiss', '已驳回举报')}
/>
</div>
) : (
<span className="text-xs text-muted-foreground">
{row.original.handled_at
? new Date(row.original.handled_at).toLocaleString('zh-CN')
: '—'}
</span>
),
) : (
row.original.open_reports_count > 0 && (
<>
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`屏蔽事件「${row.original.title ?? row.original.id}」?`}
description="举报成立:事件将对用户不可见(可撤销),其全部待处理举报标记为已处理"
destructive
onConfirm={() => act(row.original, 'approve', '已屏蔽事件')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title="驳回该事件的全部待处理举报?"
description="举报不成立:事件保持原样。"
confirmLabel="驳回"
onConfirm={() => act(row.original, 'reject', '已驳回举报')}
/>
</>
)
)}
</div>
),
},
]
return (
<div>
<PageHeader
title="事件举报"
description="用户对事迹档案事件的举报,可隐藏、删除事件或驳回举报"
title="被举报事件"
description="按事件聚合的举报,一次决定处理该事件的全部待处理举报"
actions={
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="open"></SelectItem>
<SelectItem value="resolved"></SelectItem>
<SelectItem value="dismissed"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="blocked"></SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
}
@ -166,10 +160,16 @@ function EventReportsPage() {
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有相关举报"
emptyText="没有被举报的事件"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<ReportsSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={`事件「${detail?.title ?? detail?.id ?? ''}」的举报`}
reports={detail?.reports ?? []}
/>
</div>
)
}