feat(moderation): comment/event report and dimzou translation pages

New 内容审核 nav group covering the remaining admin API groups:
- /comment-reports — status filter; hide/delete comment or dismiss
- /event-reports — status filter; hide/delete/resolve/dismiss
- /dimzou-translations — language/status filters, draft block progress,
  requeue machine translation

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-11 13:28:28 +08:00
co-authored by Claude Fable 5
parent ebe7665620
commit 999d5353fd
11 changed files with 908 additions and 9 deletions
+165
View File
@@ -0,0 +1,165 @@
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 { 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 { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/comment-reports')({
component: CommentReportsPage,
})
function CommentReportsPage() {
const [status, setStatus] = useState<string>('open')
const list = useListPage(
'comment-reports',
fetchCommentReports,
status === 'all' ? {} : { status },
)
const act = async (row: CommentReport, action: CommentReportAction, done: string) => {
try {
await handleCommentReport(row.id, action)
toast.success(done)
void list.query.refetch()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<CommentReport>[] = [
{
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>
<code className="font-mono text-xs text-muted-foreground">#{row.original.comment_id}</code>
</div>
),
},
{
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>
),
},
{
header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />,
},
{
header: '举报时间',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_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', '已隐藏评论')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title={`删除评论 #${row.original.comment_id}`}
description="评论将被删除,举报标记为已处理。"
confirmLabel="删除"
destructive
onConfirm={() => act(row.original, 'delete', '已删除评论')}
/>
<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>
),
},
]
return (
<div>
<PageHeader
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>
</SelectContent>
</Select>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有相关举报"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
</div>
)
}
+171
View File
@@ -0,0 +1,171 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { DimzouTranslation } from '@/api/types'
import { fetchDimzouTranslations, retranslate } from '@/api/modules/dimzou'
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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/dimzou-translations')({
component: DimzouTranslationsPage,
})
const STATUS_LABELS: Record<DimzouTranslation['status'], string> = {
draft: '草稿',
awaiting_publish: '待发布',
published: '已发布',
}
function DimzouTranslationsPage() {
const [status, setStatus] = useState<string>('all')
// language filter applies on Enter/blur to avoid a request per keystroke
const [languageInput, setLanguageInput] = useState('')
const [language, setLanguage] = useState('')
const filters: Record<string, string> = {}
if (status !== 'all') filters.status = status
if (language) filters.language_code = language
const list = useListPage('dimzou-translations', fetchDimzouTranslations, filters)
const requeue = async (row: DimzouTranslation) => {
try {
const res = await retranslate(row.id)
toast.success(`已入队重译,待译块 ${res.data.queued_blocks}`)
void list.query.refetch()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<DimzouTranslation>[] = [
{
header: '源文档',
cell: ({ row }) => (
<div className="max-w-72">
<div className="truncate font-medium" title={row.original.source_document?.title ?? undefined}>
{row.original.source_document?.title ?? '—'}
</div>
<code className="font-mono text-xs text-muted-foreground">
#{row.original.translated_document_id}
</code>
</div>
),
},
{
header: '目标语言',
cell: ({ row }) => <Badge variant="secondary">{row.original.language_code}</Badge>,
},
{
header: '状态',
cell: ({ row }) => STATUS_LABELS[row.original.status] ?? row.original.status,
},
{
header: '翻译进度',
cell: ({ row }) => {
const { total, needs_translation, translation_failed } = row.original.blocks
if (!row.original.draft_version_id) {
return <span className="text-xs text-muted-foreground">稿</span>
}
return (
<div className="text-sm">
<span>{total - needs_translation - translation_failed}/{total} </span>
{needs_translation > 0 && (
<span className="ml-2 text-amber-600 dark:text-amber-400"> {needs_translation}</span>
)}
{translation_failed > 0 && (
<span className="ml-2 text-destructive"> {translation_failed}</span>
)}
</div>
)
},
},
{
header: '创建人',
cell: ({ row }) => (
<div>
<div className="text-sm">{row.original.creator?.display_name ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
{row.original.created_by_uid}
</code>
</div>
),
},
{
header: '更新时间',
cell: ({ row }) =>
row.original.updated_at ? new Date(row.original.updated_at).toLocaleString('zh-CN') : '—',
},
{
header: '操作',
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
</Button>
}
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}`}
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
onConfirm={() => requeue(row.original)}
/>
),
},
]
return (
<div>
<PageHeader
title="Dimzou 翻译"
description="全站译文档与块翻译进度,可将待译/失败的块重新入队机器翻译"
actions={
<div className="flex items-center gap-2">
<Input
value={languageInput}
onChange={(e) => setLanguageInput(e.target.value)}
onBlur={() => setLanguage(languageInput.trim())}
onKeyDown={(e) => {
if (e.key === 'Enter') setLanguage(languageInput.trim())
}}
placeholder="语言,如 en"
className="h-8 w-28"
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="awaiting_publish"></SelectItem>
<SelectItem value="published"></SelectItem>
</SelectContent>
</Select>
</div>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有译文档"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
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 { 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 { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/event-reports')({
component: EventReportsPage,
})
function EventReportsPage() {
const [status, setStatus] = useState<string>('open')
const list = useListPage(
'event-reports',
fetchEventReports,
status === 'all' ? {} : { status },
)
const act = async (row: FileXEventReport, action: EventReportAction, done: string) => {
try {
await handleEventReport(row.id, action)
toast.success(done)
void list.query.refetch()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<FileXEventReport>[] = [
{
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>
<code className="font-mono text-xs text-muted-foreground">#{row.original.event_id}</code>
</div>
),
},
{
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>
),
},
{
header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />,
},
{
header: '举报时间',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_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', '已隐藏事件')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title={`删除事件 #${row.original.event_id}`}
description="事件将被删除,举报标记为已处理。"
confirmLabel="删除"
destructive
onConfirm={() => act(row.original, 'delete', '已删除事件')}
/>
<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>
),
},
]
return (
<div>
<PageHeader
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>
</SelectContent>
</Select>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有相关举报"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
</div>
)
}