diff --git a/src/api/modules/comments.ts b/src/api/modules/comments.ts new file mode 100644 index 0000000..1836b30 --- /dev/null +++ b/src/api/modules/comments.ts @@ -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>(`${BASE}/reports`, params) +} + +export function handleCommentReport(id: number, action: CommentReportAction) { + return api.patch>(`${BASE}/reports/${id}`, { action }) +} diff --git a/src/api/modules/dimzou.ts b/src/api/modules/dimzou.ts new file mode 100644 index 0000000..b229a2a --- /dev/null +++ b/src/api/modules/dimzou.ts @@ -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>(`${BASE}/translations`, params) +} + +export function retranslate(id: number) { + return api.post>(`${BASE}/translations/${id}/retranslate`) +} diff --git a/src/api/modules/filex.ts b/src/api/modules/filex.ts new file mode 100644 index 0000000..e9eff8e --- /dev/null +++ b/src/api/modules/filex.ts @@ -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>(`${BASE}/event-reports`, params) +} + +export function handleEventReport(id: number, action: EventReportAction) { + return api.patch>(`${BASE}/event-reports/${id}`, { action }) +} diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 5c9c538..fc44d57 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -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 */ diff --git a/src/api/types.ts b/src/api/types.ts index c4db5ae..88eb40b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 +} diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index 1af7550..7969b64 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -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: [ diff --git a/src/features/reports/report-status-badge.tsx b/src/features/reports/report-status-badge.tsx new file mode 100644 index 0000000..805957c --- /dev/null +++ b/src/features/reports/report-status-badge.tsx @@ -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 待处理 + } + if (status === 'resolved') { + return 已处理 + } + return 已驳回 +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index bfd873e..9b2ddcc 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/_authed/comment-reports.tsx b/src/routes/_authed/comment-reports.tsx new file mode 100644 index 0000000..625a907 --- /dev/null +++ b/src/routes/_authed/comment-reports.tsx @@ -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('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[] = [ + { + header: '举报原因', + cell: ({ row }) => ( +
+
{row.original.reason}
+ {row.original.message ? ( +
+ {row.original.message} +
+ ) : null} +
+ ), + }, + { + header: '被举报评论', + cell: ({ row }) => ( +
+
+ {row.original.comment?.body ?? `#${row.original.comment_id}(已不存在)`} +
+ #{row.original.comment_id} +
+ ), + }, + { + header: '举报人', + cell: ({ row }) => ( +
+
{row.original.reporter?.display_name ?? '—'}
+ + {row.original.reporter?.uid ?? ''} + +
+ ), + }, + { + header: '状态', + cell: ({ row }) => , + }, + { + header: '举报时间', + cell: ({ row }) => + row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—', + }, + { + header: '操作', + cell: ({ row }) => + row.original.status === 'open' ? ( +
+ 隐藏评论} + title={`隐藏评论 #${row.original.comment_id}?`} + description="评论将对用户不可见,举报标记为已处理。" + onConfirm={() => act(row.original, 'hide', '已隐藏评论')} + /> + + 删除评论 + + } + title={`删除评论 #${row.original.comment_id}?`} + description="评论将被删除,举报标记为已处理。" + confirmLabel="删除" + destructive + onConfirm={() => act(row.original, 'delete', '已删除评论')} + /> + + 驳回 + + } + title="驳回该举报?" + description="评论保持原样,举报标记为已驳回。" + confirmLabel="驳回" + onConfirm={() => act(row.original, 'dismiss', '已驳回举报')} + /> +
+ ) : ( + + {row.original.handled_at + ? new Date(row.original.handled_at).toLocaleString('zh-CN') + : '—'} + + ), + }, + ] + + return ( +
+ + + + + + 全部状态 + 待处理 + 已处理 + 已驳回 + + + } + /> + +
+ ) +} diff --git a/src/routes/_authed/dimzou-translations.tsx b/src/routes/_authed/dimzou-translations.tsx new file mode 100644 index 0000000..cda2961 --- /dev/null +++ b/src/routes/_authed/dimzou-translations.tsx @@ -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 = { + draft: '草稿', + awaiting_publish: '待发布', + published: '已发布', +} + +function DimzouTranslationsPage() { + const [status, setStatus] = useState('all') + // language filter applies on Enter/blur to avoid a request per keystroke + const [languageInput, setLanguageInput] = useState('') + const [language, setLanguage] = useState('') + const filters: Record = {} + 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[] = [ + { + header: '源文档', + cell: ({ row }) => ( +
+
+ {row.original.source_document?.title ?? '—'} +
+ + 译文档 #{row.original.translated_document_id} + +
+ ), + }, + { + header: '目标语言', + cell: ({ row }) => {row.original.language_code}, + }, + { + 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 无草稿版本 + } + return ( +
+ {total - needs_translation - translation_failed}/{total} 已译 + {needs_translation > 0 && ( + 待译 {needs_translation} + )} + {translation_failed > 0 && ( + 失败 {translation_failed} + )} +
+ ) + }, + }, + { + header: '创建人', + cell: ({ row }) => ( +
+
{row.original.creator?.display_name ?? '—'}
+ + {row.original.created_by_uid} + +
+ ), + }, + { + header: '更新时间', + cell: ({ row }) => + row.original.updated_at ? new Date(row.original.updated_at).toLocaleString('zh-CN') : '—', + }, + { + header: '操作', + cell: ({ row }) => ( + + 重新机器翻译 + + } + title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`} + description="将最新草稿版本中待译和失败的块重新入队机器翻译。" + onConfirm={() => requeue(row.original)} + /> + ), + }, + ] + + return ( +
+ + setLanguageInput(e.target.value)} + onBlur={() => setLanguage(languageInput.trim())} + onKeyDown={(e) => { + if (e.key === 'Enter') setLanguage(languageInput.trim()) + }} + placeholder="语言,如 en" + className="h-8 w-28" + /> + +
+ } + /> + + + ) +} diff --git a/src/routes/_authed/event-reports.tsx b/src/routes/_authed/event-reports.tsx new file mode 100644 index 0000000..3401eea --- /dev/null +++ b/src/routes/_authed/event-reports.tsx @@ -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('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[] = [ + { + header: '举报原因', + cell: ({ row }) => ( +
+
{row.original.reason}
+ {row.original.message ? ( +
+ {row.original.message} +
+ ) : null} +
+ ), + }, + { + header: '被举报事件', + cell: ({ row }) => ( +
+
+ {row.original.event?.title ?? `#${row.original.event_id}(已删除)`} +
+ #{row.original.event_id} +
+ ), + }, + { + header: '举报人', + cell: ({ row }) => ( +
+
{row.original.reporter?.display_name ?? '—'}
+ + {row.original.reporter?.uid ?? ''} + +
+ ), + }, + { + header: '状态', + cell: ({ row }) => , + }, + { + header: '举报时间', + cell: ({ row }) => + row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—', + }, + { + header: '操作', + cell: ({ row }) => + row.original.status === 'open' ? ( +
+ 隐藏事件} + title={`隐藏事件 #${row.original.event_id}?`} + description="事件将对用户不可见,举报标记为已处理。" + onConfirm={() => act(row.original, 'hide', '已隐藏事件')} + /> + + 删除事件 + + } + title={`删除事件 #${row.original.event_id}?`} + description="事件将被删除,举报标记为已处理。" + confirmLabel="删除" + destructive + onConfirm={() => act(row.original, 'delete', '已删除事件')} + /> + + 标记已处理 + + } + title="标记该举报为已处理?" + description="事件保持原样,仅将举报标记为已处理。" + onConfirm={() => act(row.original, 'resolve', '已标记处理')} + /> + + 驳回 + + } + title="驳回该举报?" + description="事件保持原样,举报标记为已驳回。" + confirmLabel="驳回" + onConfirm={() => act(row.original, 'dismiss', '已驳回举报')} + /> +
+ ) : ( + + {row.original.handled_at + ? new Date(row.original.handled_at).toLocaleString('zh-CN') + : '—'} + + ), + }, + ] + + return ( +
+ + + + + + 全部状态 + 待处理 + 已处理 + 已驳回 + + + } + /> + +
+ ) +}