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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user