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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:28:28 +08:00
parent ebe7665620
commit 999d5353fd
11 changed files with 908 additions and 9 deletions

View File

@ -0,0 +1,14 @@
import { api } from '@/api/client'
import type { ApiResponse, CommentReport, ListParams, PaginatedResponse } from '@/api/types'
const BASE = '/api/admin/v1/comments'
export type CommentReportAction = 'hide' | 'delete' | 'dismiss'
export function fetchCommentReports(params: ListParams) {
return api.get<PaginatedResponse<CommentReport>>(`${BASE}/reports`, params)
}
export function handleCommentReport(id: number, action: CommentReportAction) {
return api.patch<ApiResponse<CommentReport>>(`${BASE}/reports/${id}`, { action })
}

18
src/api/modules/dimzou.ts Normal file
View File

@ -0,0 +1,18 @@
import { api } from '@/api/client'
import type {
ApiResponse,
DimzouTranslation,
ListParams,
PaginatedResponse,
RetranslateResult,
} from '@/api/types'
const BASE = '/api/admin/v1/dimzou'
export function fetchDimzouTranslations(params: ListParams) {
return api.get<PaginatedResponse<DimzouTranslation>>(`${BASE}/translations`, params)
}
export function retranslate(id: number) {
return api.post<ApiResponse<RetranslateResult>>(`${BASE}/translations/${id}/retranslate`)
}

14
src/api/modules/filex.ts Normal file
View File

@ -0,0 +1,14 @@
import { api } from '@/api/client'
import type { ApiResponse, FileXEventReport, ListParams, PaginatedResponse } from '@/api/types'
const BASE = '/api/admin/v1/file-x'
export type EventReportAction = 'hide' | 'delete' | 'resolve' | 'dismiss'
export function fetchEventReports(params: ListParams) {
return api.get<PaginatedResponse<FileXEventReport>>(`${BASE}/event-reports`, params)
}
export function handleEventReport(id: number, action: EventReportAction) {
return api.patch<ApiResponse<FileXEventReport>>(`${BASE}/event-reports/${id}`, { action })
}

View File

@ -253,7 +253,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example dismissed
* @example resolved
* @enum {string|null}
*/
status?: "open" | "resolved" | "dismissed" | null;
@ -284,7 +284,7 @@ export interface paths {
* {
* "id": 1,
* "comment_id": 10,
* "reason": "invalid",
* "reason": "spam",
* "message": "Contains spam links",
* "status": "open",
* "created_at": "2025-12-01T10:10:00Z",
@ -306,7 +306,7 @@ export interface paths {
id?: number;
/** @example 10 */
comment_id?: number;
/** @example invalid */
/** @example spam */
reason?: string;
/** @example Contains spam links */
message?: string;
@ -420,7 +420,7 @@ export interface paths {
path: {
/**
* @description The ID of the report.
* @example 16
* @example 3
*/
report_id: number;
/**
@ -445,7 +445,7 @@ export interface paths {
path: {
/**
* @description The ID of the report.
* @example 16
* @example 3
*/
report_id: number;
/**
@ -565,6 +565,197 @@ export interface paths {
};
trace?: never;
};
"/api/admin/v1/dimzou/translations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* [运营] 译文列表
* @description 分页列出全站译文档,含最新草稿版本的块翻译进度,用于决定是否重新入队翻译。仅平台管理员可用。
*/
get: {
parameters: {
query?: {
/**
* @description 可选。按目标语言过滤。 示例: fr
* @example architecto
*/
language_code?: string;
/**
* @description 可选。按状态过滤: draft, awaiting_publish, published。 示例: draft
* @example architecto
*/
status?: string;
/**
* @description 可选。按源文档过滤。 示例: 1
* @example 16
*/
source_document_id?: number;
/**
* @description 可选。页码。 示例: 1
* @example 16
*/
page?: number;
/**
* @description 可选。每页数量最大100。 示例: 20
* @example 16
*/
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/**
* @description Must not be greater than 8 characters.
* @example bngzmiyv
*/
language_code?: string;
/**
* @example draft
* @enum {string}
*/
status?: "draft" | "awaiting_publish" | "published";
/** @example 16 */
source_document_id?: number;
/**
* @description Must be at least 1.
* @example 22
*/
page?: number;
/**
* @description Must be at least 1. Must not be greater than 100.
* @example 7
*/
page_size?: number;
};
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example true */
success?: boolean;
/**
* @example [
* {
* "kind": "dimzou_document_translation",
* "id": 1,
* "language_code": "fr",
* "status": "draft",
* "source_document": {
* "id": 10,
* "title": "家电维修入门"
* },
* "translated_document_id": 11,
* "created_by_uid": "01HXXXX",
* "creator": {
* "uid": "01HXXXX",
* "display_name": "Alice"
* },
* "draft_version_id": 5,
* "blocks": {
* "total": 12,
* "needs_translation": 3,
* "translation_failed": 1
* },
* "created_at": "2026-06-01T10:00:00Z",
* "updated_at": "2026-06-02T10:00:00Z"
* }
* ]
*/
data?: {
/** @example dimzou_document_translation */
kind?: string;
/** @example 1 */
id?: number;
/** @example fr */
language_code?: string;
/** @example draft */
status?: string;
source_document?: {
/** @example 10 */
id?: number;
/** @example 家电维修入门 */
title?: string;
};
/** @example 11 */
translated_document_id?: number;
/** @example 01HXXXX */
created_by_uid?: string;
creator?: {
/** @example 01HXXXX */
uid?: string;
/** @example Alice */
display_name?: string;
};
/** @example 5 */
draft_version_id?: number;
blocks?: {
/** @example 12 */
total?: number;
/** @example 3 */
needs_translation?: number;
/** @example 1 */
translation_failed?: number;
};
/** @example 2026-06-01T10:00:00Z */
created_at?: string;
/** @example 2026-06-02T10:00:00Z */
updated_at?: string;
}[];
pagination?: {
/** @example 1 */
current_page?: number;
/** @example null */
next_page?: string;
/** @example 20 */
page_size?: number;
/** @example null */
prev_page?: string;
/** @example 1 */
total_count?: number;
/** @example 1 */
total_pages?: number;
};
};
};
};
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example false */
success?: boolean;
/** @example FORBIDDEN */
code?: string;
/** @example 无权限访问 */
message?: string;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/dimzou/translations/{translation_id}/retranslate": {
parameters: {
query?: never;
@ -690,7 +881,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example android
* @example ios
* @enum {string|null}
*/
platform?: "ios" | "android" | "web" | "desktop" | null;
@ -1155,7 +1346,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example resolved
* @example open
* @enum {string|null}
*/
status?: "open" | "resolved" | "dismissed" | null;
@ -1290,7 +1481,7 @@ export interface paths {
path: {
/**
* @description The ID of the report.
* @example 16
* @example 1
*/
report_id: number;
/** @description Optional parameter. 必需。举报ID。 示例: 1 */
@ -1312,7 +1503,7 @@ export interface paths {
path: {
/**
* @description The ID of the report.
* @example 16
* @example 1
*/
report_id: number;
/** @description Optional parameter. 必需。举报ID。 示例: 1 */

View File

@ -284,3 +284,68 @@ export interface NotificationDevice {
last_seen_at?: string | null
created_at?: string
}
export type ReportStatus = 'open' | 'resolved' | 'dismissed'
export interface ReportUser {
uid: string
display_name?: string | null
}
export interface CommentReport {
id: number
comment_id: number
reason: string
message?: string | null
status: ReportStatus
created_at: string
handled_at?: string | null
handled_by?: ReportUser | null
reporter?: ReportUser | null
comment?: {
id: number
body?: string | null
status?: string
author?: ReportUser | null
} | null
}
export interface FileXEventReport {
id: number
kind?: string
event_id: number
reason: string
message?: string | null
status: ReportStatus
created_at: string
handled_at?: string | null
handled_by?: ReportUser | null
reporter?: ReportUser | null
event?: { id: number; title?: string | null } | null
}
export type DimzouTranslationStatus = 'draft' | 'awaiting_publish' | 'published'
export interface DimzouTranslation {
kind?: string
id: number
language_code: string
status: DimzouTranslationStatus
source_document?: { id: number; title?: string | null } | null
translated_document_id: number
created_by_uid: string
creator?: ReportUser | null
draft_version_id?: number | null
blocks: {
total: number
needs_translation: number
translation_failed: number
}
created_at?: string
updated_at?: string
}
export interface RetranslateResult {
translation_version_id: number
queued_blocks: number
}

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import {
Bot,
Flag,
FolderTree,
Hash,
Inbox,
@ -8,6 +9,7 @@ import {
Languages,
LayoutList,
ListChecks,
MessageSquareWarning,
Newspaper,
Palette,
ScrollText,
@ -53,6 +55,14 @@ export const NAV_GROUPS: NavGroup[] = [
},
],
},
{
label: '内容审核',
items: [
{ label: '评论举报', to: '/comment-reports', icon: MessageSquareWarning },
{ label: '事件举报', to: '/event-reports', icon: Flag },
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages },
],
},
{
label: '用户与系统',
items: [

View File

@ -0,0 +1,12 @@
import type { ReportStatus } from '@/api/types'
import { Badge } from '@/components/ui/badge'
export function ReportStatusBadge({ status }: { status: ReportStatus }) {
if (status === 'open') {
return <Badge className="bg-amber-500/12 text-amber-700 dark:text-amber-400"></Badge>
}
if (status === 'resolved') {
return <Badge className="bg-emerald-500/12 text-emerald-700 dark:text-emerald-400"></Badge>
}
return <Badge variant="secondary"></Badge>
}

View File

@ -15,7 +15,10 @@ import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedSidRouteImport } from './routes/_authed/sid'
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports'
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics'
import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets'
@ -55,11 +58,27 @@ const AuthedLocalesRoute = AuthedLocalesRouteImport.update({
path: '/locales',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedEventReportsRoute = AuthedEventReportsRouteImport.update({
id: '/event-reports',
path: '/event-reports',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouTranslationsRoute =
AuthedDimzouTranslationsRouteImport.update({
id: '/dimzou-translations',
path: '/dimzou-translations',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDevicesRoute = AuthedDevicesRouteImport.update({
id: '/devices',
path: '/devices',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
id: '/comment-reports',
path: '/comment-reports',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
id: '/categories/',
path: '/categories/',
@ -112,7 +131,10 @@ const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof AuthedIndexRoute
'/login': typeof LoginRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute
'/locales': typeof AuthedLocalesRoute
'/robots': typeof AuthedRobotsRoute
'/sid': typeof AuthedSidRoute
@ -128,7 +150,10 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute
'/locales': typeof AuthedLocalesRoute
'/robots': typeof AuthedRobotsRoute
'/sid': typeof AuthedSidRoute
@ -147,7 +172,10 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authed': typeof AuthedRouteWithChildren
'/login': typeof LoginRoute
'/_authed/comment-reports': typeof AuthedCommentReportsRoute
'/_authed/devices': typeof AuthedDevicesRoute
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/_authed/event-reports': typeof AuthedEventReportsRoute
'/_authed/locales': typeof AuthedLocalesRoute
'/_authed/robots': typeof AuthedRobotsRoute
'/_authed/sid': typeof AuthedSidRoute
@ -167,7 +195,10 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
| '/comment-reports'
| '/devices'
| '/dimzou-translations'
| '/event-reports'
| '/locales'
| '/robots'
| '/sid'
@ -183,7 +214,10 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/comment-reports'
| '/devices'
| '/dimzou-translations'
| '/event-reports'
| '/locales'
| '/robots'
| '/sid'
@ -201,7 +235,10 @@ export interface FileRouteTypes {
| '__root__'
| '/_authed'
| '/login'
| '/_authed/comment-reports'
| '/_authed/devices'
| '/_authed/dimzou-translations'
| '/_authed/event-reports'
| '/_authed/locales'
| '/_authed/robots'
| '/_authed/sid'
@ -266,6 +303,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedLocalesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/event-reports': {
id: '/_authed/event-reports'
path: '/event-reports'
fullPath: '/event-reports'
preLoaderRoute: typeof AuthedEventReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou-translations': {
id: '/_authed/dimzou-translations'
path: '/dimzou-translations'
fullPath: '/dimzou-translations'
preLoaderRoute: typeof AuthedDimzouTranslationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/devices': {
id: '/_authed/devices'
path: '/devices'
@ -273,6 +324,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedDevicesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/comment-reports': {
id: '/_authed/comment-reports'
path: '/comment-reports'
fullPath: '/comment-reports'
preLoaderRoute: typeof AuthedCommentReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/': {
id: '/_authed/categories/'
path: '/categories'
@ -340,7 +398,10 @@ declare module '@tanstack/react-router' {
}
interface AuthedRouteChildren {
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
AuthedDevicesRoute: typeof AuthedDevicesRoute
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
AuthedEventReportsRoute: typeof AuthedEventReportsRoute
AuthedLocalesRoute: typeof AuthedLocalesRoute
AuthedRobotsRoute: typeof AuthedRobotsRoute
AuthedSidRoute: typeof AuthedSidRoute
@ -357,7 +418,10 @@ interface AuthedRouteChildren {
}
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedCommentReportsRoute: AuthedCommentReportsRoute,
AuthedDevicesRoute: AuthedDevicesRoute,
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
AuthedEventReportsRoute: AuthedEventReportsRoute,
AuthedLocalesRoute: AuthedLocalesRoute,
AuthedRobotsRoute: AuthedRobotsRoute,
AuthedSidRoute: AuthedSidRoute,

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>
)
}

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>
)
}

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>
)
}