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:
14
src/api/modules/comments.ts
Normal file
14
src/api/modules/comments.ts
Normal 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
18
src/api/modules/dimzou.ts
Normal 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
14
src/api/modules/filex.ts
Normal 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 })
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
|
||||
12
src/features/reports/report-status-badge.tsx
Normal file
12
src/features/reports/report-status-badge.tsx
Normal 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>
|
||||
}
|
||||
@ -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,
|
||||
|
||||
165
src/routes/_authed/comment-reports.tsx
Normal file
165
src/routes/_authed/comment-reports.tsx
Normal 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
src/routes/_authed/dimzou-translations.tsx
Normal file
171
src/routes/_authed/dimzou-translations.tsx
Normal 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
src/routes/_authed/event-reports.tsx
Normal file
175
src/routes/_authed/event-reports.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user