feat(moderation): resource-centric reported comments/events pages

Rework 内容审核 pages around reported resources instead of individual
reports, matching the new backend endpoints: rows group all reports on
one comment/event, with a slide-over listing report details and
per-row actions 通过并屏蔽 (approve) / 驳回举报 (reject) / 撤销屏蔽
(revert). Filters: 待处理 / 已屏蔽 / 全部.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-11 14:20:37 +08:00
co-authored by Claude Fable 5
parent 999d5353fd
commit f5a7249969
9 changed files with 532 additions and 381 deletions
+6 -6
View File
@@ -1,14 +1,14 @@
import { api } from '@/api/client' import { api } from '@/api/client'
import type { ApiResponse, CommentReport, ListParams, PaginatedResponse } from '@/api/types' import type { ApiResponse, ListParams, PaginatedResponse, ReportedComment } from '@/api/types'
const BASE = '/api/admin/v1/comments' const BASE = '/api/admin/v1/comments'
export type CommentReportAction = 'hide' | 'delete' | 'dismiss' export type ModerationAction = 'approve' | 'reject' | 'revert'
export function fetchCommentReports(params: ListParams) { export function fetchReportedComments(params: ListParams) {
return api.get<PaginatedResponse<CommentReport>>(`${BASE}/reports`, params) return api.get<PaginatedResponse<ReportedComment>>(`${BASE}/reported`, params)
} }
export function handleCommentReport(id: number, action: CommentReportAction) { export function moderateReportedComment(id: number, action: ModerationAction) {
return api.patch<ApiResponse<CommentReport>>(`${BASE}/reports/${id}`, { action }) return api.patch<ApiResponse<ReportedComment>>(`${BASE}/reported/${id}`, { action })
} }
+6 -7
View File
@@ -1,14 +1,13 @@
import { api } from '@/api/client' import { api } from '@/api/client'
import type { ApiResponse, FileXEventReport, ListParams, PaginatedResponse } from '@/api/types' import type { ApiResponse, ListParams, PaginatedResponse, ReportedEvent } from '@/api/types'
import type { ModerationAction } from '@/api/modules/comments'
const BASE = '/api/admin/v1/file-x' const BASE = '/api/admin/v1/file-x'
export type EventReportAction = 'hide' | 'delete' | 'resolve' | 'dismiss' export function fetchReportedEvents(params: ListParams) {
return api.get<PaginatedResponse<ReportedEvent>>(`${BASE}/reported-events`, params)
export function fetchEventReports(params: ListParams) {
return api.get<PaginatedResponse<FileXEventReport>>(`${BASE}/event-reports`, params)
} }
export function handleEventReport(id: number, action: EventReportAction) { export function moderateReportedEvent(id: number, action: ModerationAction) {
return api.patch<ApiResponse<FileXEventReport>>(`${BASE}/event-reports/${id}`, { action }) return api.patch<ApiResponse<ReportedEvent>>(`${BASE}/reported-events/${id}`, { action })
} }
+219 -147
View File
@@ -218,30 +218,33 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/v1/comments/reports": { "/api/admin/v1/comments/reported": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** [评论举报] 获取举报列表 */ /**
* [评论审核] 被举报评论列表
* @description 按被举报的评论分组列出举报,附带举报明细与屏蔽状态。
*/
get: { get: {
parameters: { parameters: {
query?: { query?: {
/** /**
* @description optional Filter by status: open, resolved, dismissed. * @description 可选。按状态过滤: pending(有待处理举报), blocked(已屏蔽), all(默认)。 示例: pending
* @example open * @example architecto
*/ */
status?: string; status?: string;
/** /**
* @description optional Page number. * @description 可选。页码。 示例: 1
* @example 1 * @example 16
*/ */
page?: number; page?: number;
/** /**
* @description optional Page size (max 100). * @description 可选。每页数量,最大100。 示例: 20
* @example 20 * @example 16
*/ */
page_size?: number; page_size?: number;
}; };
@@ -253,10 +256,10 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example resolved * @example pending
* @enum {string|null} * @enum {string|null}
*/ */
status?: "open" | "resolved" | "dismissed" | null; status?: "pending" | "blocked" | "all" | null;
/** /**
* @description Must be at least 1. * @description Must be at least 1.
* @example 16 * @example 16
@@ -282,54 +285,103 @@ export interface paths {
/** /**
* @example [ * @example [
* { * {
* "kind": "reported_comment",
* "id": 10,
* "body": "这条评论包含垃圾推广链接",
* "body_format": "markdown",
* "status": "active",
* "is_blocked": false,
* "blocked_at": null,
* "blocked_by_uid": null,
* "author": {
* "uid": "01HYYYY",
* "display_name": "Bob"
* },
* "open_reports_count": 2,
* "latest_report_at": "2026-07-11T10:10:00Z",
* "reports": [
* {
* "id": 1, * "id": 1,
* "comment_id": 10,
* "reason": "spam", * "reason": "spam",
* "message": "Contains spam links", * "message": "含有垃圾推广链接",
* "status": "open", * "status": "open",
* "created_at": "2025-12-01T10:10:00Z",
* "handled_at": null,
* "handled_by": null,
* "reporter": { * "reporter": {
* "uid": "01HXXXX", * "uid": "01HXXXX",
* "display_name": "Alice" * "display_name": "Alice"
* }, * },
* "comment": { * "created_at": "2026-07-11T10:10:00Z",
* "id": 10, * "handled_at": null
* "body": "Root comment"
* } * }
* ],
* "created_at": "2026-07-10T09:00:00Z"
* } * }
* ] * ]
*/ */
data?: { data?: {
/** @example reported_comment */
kind?: string;
/** @example 10 */
id?: number;
/** @example 这条评论包含垃圾推广链接 */
body?: string;
/** @example markdown */
body_format?: string;
/** @example active */
status?: string;
/** @example false */
is_blocked?: boolean;
/** @example null */
blocked_at?: string;
/** @example null */
blocked_by_uid?: string;
author?: {
/** @example 01HYYYY */
uid?: string;
/** @example Bob */
display_name?: string;
};
/** @example 2 */
open_reports_count?: number;
/** @example 2026-07-11T10:10:00Z */
latest_report_at?: string;
/**
* @example [
* {
* "id": 1,
* "reason": "spam",
* "message": "含有垃圾推广链接",
* "status": "open",
* "reporter": {
* "uid": "01HXXXX",
* "display_name": "Alice"
* },
* "created_at": "2026-07-11T10:10:00Z",
* "handled_at": null
* }
* ]
*/
reports?: {
/** @example 1 */ /** @example 1 */
id?: number; id?: number;
/** @example 10 */
comment_id?: number;
/** @example spam */ /** @example spam */
reason?: string; reason?: string;
/** @example Contains spam links */ /** @example 含有垃圾推广链接 */
message?: string; message?: string;
/** @example open */ /** @example open */
status?: string; status?: string;
/** @example 2025-12-01T10:10:00Z */
created_at?: string;
/** @example null */
handled_at?: string;
/** @example null */
handled_by?: string;
reporter?: { reporter?: {
/** @example 01HXXXX */ /** @example 01HXXXX */
uid?: string; uid?: string;
/** @example Alice */ /** @example Alice */
display_name?: string; display_name?: string;
}; };
comment?: { /** @example 2026-07-11T10:10:00Z */
/** @example 10 */ created_at?: string;
id?: number; /** @example null */
/** @example Root comment */ handled_at?: string;
body?: string; }[];
}; /** @example 2026-07-10T09:00:00Z */
created_at?: string;
}[]; }[];
pagination?: { pagination?: {
/** @example 1 */ /** @example 1 */
@@ -378,31 +430,6 @@ export interface paths {
}; };
}; };
}; };
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example false */
success?: boolean;
/** @example VALIDATION_ERROR */
code?: string;
/** @example The given data was invalid. */
message?: string;
data?: {
errors?: {
/**
* @example [
* "The selected status is invalid."
* ]
*/
status?: string[];
};
};
};
};
};
}; };
}; };
put?: never; put?: never;
@@ -413,21 +440,16 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/v1/comments/reports/{report_id}": { "/api/admin/v1/comments/reported/{comment}": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
/** /**
* @description The ID of the report. * @description 评论 ID。
* @example 3 * @example 10
*/ */
report_id: number; comment: number;
/**
* @description Report ID.
* @example 1
*/
report: number;
}; };
cookie?: never; cookie?: never;
}; };
@@ -437,22 +459,22 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** [评论举报] 处理举报 */ /**
* [评论审核] 处理被举报评论
* @description approve: 举报成立,屏蔽评论并将其全部待处理举报标记已处理;
* reject: 举报不成立,驳回全部待处理举报;
* revert: 撤销屏蔽(仅对已屏蔽评论有效),举报记录保留。
*/
patch: { patch: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
/** /**
* @description The ID of the report. * @description 评论 ID。
* @example 3 * @example 10
*/ */
report_id: number; comment: number;
/**
* @description Report ID.
* @example 1
*/
report: number;
}; };
cookie?: never; cookie?: never;
}; };
@@ -460,8 +482,8 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @description Action to take: hide, delete, dismiss. * @description 处理动作: approve, reject, revert。
* @example hide * @example approve
*/ */
action: string; action: string;
}; };
@@ -477,16 +499,20 @@ export interface paths {
/** @example true */ /** @example true */
success?: boolean; success?: boolean;
data?: { data?: {
/** @example 1 */ /** @example reported_comment */
kind?: string;
/** @example 10 */
id?: number; id?: number;
/** @example resolved */ /** @example true */
status?: string; is_blocked?: boolean;
/** @example 2025-12-01T10:20:00Z */ /** @example 2026-07-11T10:20:00Z */
handled_at?: string; blocked_at?: string;
/** @example 01HXXXX */ /** @example 01HXXXX */
handled_by_uid?: string; blocked_by_uid?: string;
/** @example 0 */
open_reports_count?: number;
}; };
/** @example Report handled */ /** @example Moderation applied */
message?: string; message?: string;
}; };
}; };
@@ -521,21 +547,6 @@ export interface paths {
}; };
}; };
}; };
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example false */
success?: boolean;
/** @example NOT_FOUND */
code?: string;
/** @example 举报不存在 */
message?: string;
};
};
};
422: { 422: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -552,7 +563,7 @@ export interface paths {
errors?: { errors?: {
/** /**
* @example [ * @example [
* "The selected action is invalid." * "Comment is not blocked."
* ] * ]
*/ */
action?: string[]; action?: string[];
@@ -881,11 +892,11 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example ios * @example android
* @enum {string|null} * @enum {string|null}
*/ */
platform?: "ios" | "android" | "web" | "desktop" | null; platform?: "ios" | "android" | "web" | "desktop" | null;
/** @example false */ /** @example true */
is_online?: boolean | null; is_online?: boolean | null;
/** /**
* @description Must be at least 1. * @description Must be at least 1.
@@ -1311,19 +1322,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/v1/file-x/event-reports": { "/api/admin/v1/file-x/reported-events": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** [事件举报] 获取举报列表 */ /**
* [事件审核] 被举报事件列表
* @description 按被举报的事件分组列出举报,附带举报明细与屏蔽状态。
*/
get: { get: {
parameters: { parameters: {
query?: { query?: {
/** /**
* @description 可选。按状态过滤: open, resolved, dismissed。 示例: open * @description 可选。按状态过滤: pending(有待处理举报), blocked(已屏蔽), all(默认)。 示例: pending
* @example architecto * @example architecto
*/ */
status?: string; status?: string;
@@ -1346,10 +1360,10 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example open * @example blocked
* @enum {string|null} * @enum {string|null}
*/ */
status?: "open" | "resolved" | "dismissed" | null; status?: "pending" | "blocked" | "all" | null;
/** /**
* @description Must be at least 1. * @description Must be at least 1.
* @example 16 * @example 16
@@ -1375,47 +1389,100 @@ export interface paths {
/** /**
* @example [ * @example [
* { * {
* "kind": "reported_event",
* "id": 25,
* "title": "空调清洗上门服务记录",
* "visibility": "public",
* "is_blocked": false,
* "blocked_at": null,
* "blocked_by_uid": null,
* "author": {
* "uid": "01HYYYY",
* "display_name": "Bob"
* },
* "open_reports_count": 2,
* "latest_report_at": "2026-07-11T10:10:00Z",
* "reports": [
* {
* "id": 1, * "id": 1,
* "kind": "filex_event_report",
* "event_id": 25,
* "reason": "spam", * "reason": "spam",
* "message": "含有大量垃圾推广链接", * "message": "疑似虚假事迹",
* "status": "open", * "status": "open",
* "created_at": "2026-06-06T10:00:00Z",
* "handled_at": null,
* "reporter": { * "reporter": {
* "uid": "01HXXXX", * "uid": "01HXXXX",
* "display_name": "Alice" * "display_name": "Alice"
* }, * },
* "handled_by": null * "created_at": "2026-07-11T10:10:00Z",
* "handled_at": null
* }
* ],
* "created_at": "2026-07-10T09:00:00Z"
* } * }
* ] * ]
*/ */
data?: { data?: {
/** @example 1 */ /** @example reported_event */
id?: number;
/** @example filex_event_report */
kind?: string; kind?: string;
/** @example 25 */ /** @example 25 */
event_id?: number; id?: number;
/** @example 空调清洗上门服务记录 */
title?: string;
/** @example public */
visibility?: string;
/** @example false */
is_blocked?: boolean;
/** @example null */
blocked_at?: string;
/** @example null */
blocked_by_uid?: string;
author?: {
/** @example 01HYYYY */
uid?: string;
/** @example Bob */
display_name?: string;
};
/** @example 2 */
open_reports_count?: number;
/** @example 2026-07-11T10:10:00Z */
latest_report_at?: string;
/**
* @example [
* {
* "id": 1,
* "reason": "spam",
* "message": "疑似虚假事迹",
* "status": "open",
* "reporter": {
* "uid": "01HXXXX",
* "display_name": "Alice"
* },
* "created_at": "2026-07-11T10:10:00Z",
* "handled_at": null
* }
* ]
*/
reports?: {
/** @example 1 */
id?: number;
/** @example spam */ /** @example spam */
reason?: string; reason?: string;
/** @example 含有大量垃圾推广链接 */ /** @example 疑似虚假事迹 */
message?: string; message?: string;
/** @example open */ /** @example open */
status?: string; status?: string;
/** @example 2026-06-06T10:00:00Z */
created_at?: string;
/** @example null */
handled_at?: string;
reporter?: { reporter?: {
/** @example 01HXXXX */ /** @example 01HXXXX */
uid?: string; uid?: string;
/** @example Alice */ /** @example Alice */
display_name?: string; display_name?: string;
}; };
/** @example 2026-07-11T10:10:00Z */
created_at?: string;
/** @example null */ /** @example null */
handled_by?: string; handled_at?: string;
}[];
/** @example 2026-07-10T09:00:00Z */
created_at?: string;
}[]; }[];
pagination?: { pagination?: {
/** @example 1 */ /** @example 1 */
@@ -1474,18 +1541,16 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/v1/file-x/event-reports/{report_id}": { "/api/admin/v1/file-x/reported-events/{event}": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
/** /**
* @description The ID of the report. * @description 事件 ID。
* @example 1 * @example 25
*/ */
report_id: number; event: number;
/** @description Optional parameter. 必需。举报ID。 示例: 1 */
report: number;
}; };
cookie?: never; cookie?: never;
}; };
@@ -1495,30 +1560,33 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** [事件举报] 处理举报 */ /**
* [事件审核] 处理被举报事件
* @description approve: 举报成立,屏蔽事件并将其全部待处理举报标记已处理;
* reject: 举报不成立,驳回全部待处理举报;
* revert: 撤销屏蔽(仅对已屏蔽事件有效),举报记录保留。
*/
patch: { patch: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
/** /**
* @description The ID of the report. * @description 事件 ID。
* @example 1 * @example 25
*/ */
report_id: number; event: number;
/** @description Optional parameter. 必需。举报ID。 示例: 1 */
report: number;
}; };
cookie?: never; cookie?: never;
}; };
requestBody?: { requestBody: {
content: { content: {
"application/json": { "application/json": {
/** /**
* @description 必需。处理动作: hide, delete, resolve, dismiss。 示例: hide * @description 处理动作: approve, reject, revert。
* @example architecto * @example approve
*/ */
action?: string; action: string;
}; };
}; };
}; };
@@ -1532,14 +1600,18 @@ export interface paths {
/** @example true */ /** @example true */
success?: boolean; success?: boolean;
data?: { data?: {
/** @example 1 */ /** @example reported_event */
kind?: string;
/** @example 25 */
id?: number; id?: number;
/** @example resolved */ /** @example true */
status?: string; is_blocked?: boolean;
/** @example 2026-06-06T10:20:00Z */ /** @example 2026-07-11T10:20:00Z */
handled_at?: string; blocked_at?: string;
/** @example 01HXXXX */ /** @example 01HXXXX */
handled_by_uid?: string; blocked_by_uid?: string;
/** @example 0 */
open_reports_count?: number;
}; };
/** @example 举报已处理 */ /** @example 举报已处理 */
message?: string; message?: string;
@@ -1607,7 +1679,7 @@ export interface paths {
errors?: { errors?: {
/** /**
* @example [ * @example [
* "The selected action is invalid." * "Event is not blocked."
* ] * ]
*/ */
action?: string[]; action?: string[];
+23 -21
View File
@@ -292,36 +292,38 @@ export interface ReportUser {
display_name?: string | null display_name?: string | null
} }
export interface CommentReport { export interface ResourceReport {
id: number id: number
comment_id: number
reason: string reason: string
message?: string | null message?: string | null
status: ReportStatus status: ReportStatus
reporter?: ReportUser | null
created_at: string created_at: string
handled_at?: string | null 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 { interface ReportedResourceBase {
id: number
kind?: string kind?: string
event_id: number id: number
reason: string is_blocked: boolean
message?: string | null blocked_at?: string | null
status: ReportStatus blocked_by_uid?: string | null
created_at: string author?: ReportUser | null
handled_at?: string | null open_reports_count: number
handled_by?: ReportUser | null latest_report_at?: string | null
reporter?: ReportUser | null reports: ResourceReport[]
event?: { id: number; title?: string | null } | null created_at?: string
}
export interface ReportedComment extends ReportedResourceBase {
body?: string | null
body_format?: string
status?: string
}
export interface ReportedEvent extends ReportedResourceBase {
title?: string | null
visibility?: string
} }
export type DimzouTranslationStatus = 'draft' | 'awaiting_publish' | 'published' export type DimzouTranslationStatus = 'draft' | 'awaiting_publish' | 'published'
+2 -2
View File
@@ -58,8 +58,8 @@ export const NAV_GROUPS: NavGroup[] = [
{ {
label: '内容审核', label: '内容审核',
items: [ items: [
{ label: '评论举报', to: '/comment-reports', icon: MessageSquareWarning }, { label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning },
{ label: '事件举报', to: '/event-reports', icon: Flag }, { label: '被举报事件', to: '/event-reports', icon: Flag },
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages }, { label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages },
], ],
}, },
@@ -1,6 +1,23 @@
import type { ReportStatus } from '@/api/types' import type { ReportStatus } from '@/api/types'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
/** State of a reported resource, derived from is_blocked + open reports. */
export function ResourceStateBadge({
isBlocked,
openReports,
}: {
isBlocked: boolean
openReports: number
}) {
if (isBlocked) {
return <Badge className="bg-red-500/12 text-red-700 dark:text-red-400"></Badge>
}
if (openReports > 0) {
return <Badge className="bg-amber-500/12 text-amber-700 dark:text-amber-400"></Badge>
}
return <Badge variant="secondary"></Badge>
}
export function ReportStatusBadge({ status }: { status: ReportStatus }) { export function ReportStatusBadge({ status }: { status: ReportStatus }) {
if (status === 'open') { if (status === 'open') {
return <Badge className="bg-amber-500/12 text-amber-700 dark:text-amber-400"></Badge> return <Badge className="bg-amber-500/12 text-amber-700 dark:text-amber-400"></Badge>
+55
View File
@@ -0,0 +1,55 @@
import type { ResourceReport } from '@/api/types'
import { ReportStatusBadge } from '@/features/reports/report-status-badge'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
interface ReportsSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
/** resource summary shown under the title */
description?: string
reports: ResourceReport[]
}
/** Slide-over listing the individual reports filed against one resource. */
export function ReportsSheet({ open, onOpenChange, title, description, reports }: ReportsSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="break-all">{title}</SheetTitle>
{description && <SheetDescription className="break-all">{description}</SheetDescription>}
</SheetHeader>
<div className="flex flex-col gap-3 px-4 pb-8">
{reports.map((report) => (
<div key={report.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{report.reason}</span>
<ReportStatusBadge status={report.status} />
</div>
{report.message ? (
<p className="mt-1 text-sm text-muted-foreground">{report.message}</p>
) : null}
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>
{report.reporter?.display_name ?? '—'}
<code className="ml-1 font-mono">{report.reporter?.uid ?? ''}</code>
</span>
<span>{new Date(report.created_at).toLocaleString('zh-CN')}</span>
</div>
</div>
))}
{reports.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</SheetContent>
</Sheet>
)
}
+81 -75
View File
@@ -2,15 +2,16 @@ import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { CommentReport } from '@/api/types' import type { ReportedComment } from '@/api/types'
import type { CommentReportAction } from '@/api/modules/comments' import type { ModerationAction } from '@/api/modules/comments'
import { fetchCommentReports, handleCommentReport } from '@/api/modules/comments' import { fetchReportedComments, moderateReportedComment } from '@/api/modules/comments'
import { handleApiError } from '@/lib/errors' import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page' import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table' import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { ReportStatusBadge } from '@/features/reports/report-status-badge' import { ResourceStateBadge } from '@/features/reports/report-status-badge'
import { ReportsSheet } from '@/features/reports/reports-sheet'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Select, Select,
@@ -21,20 +22,21 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
export const Route = createFileRoute('/_authed/comment-reports')({ export const Route = createFileRoute('/_authed/comment-reports')({
component: CommentReportsPage, component: ReportedCommentsPage,
}) })
function CommentReportsPage() { function ReportedCommentsPage() {
const [status, setStatus] = useState<string>('open') const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedComment | null>(null)
const list = useListPage( const list = useListPage(
'comment-reports', 'reported-comments',
fetchCommentReports, fetchReportedComments,
status === 'all' ? {} : { status }, status === 'all' ? {} : { status },
) )
const act = async (row: CommentReport, action: CommentReportAction, done: string) => { const act = async (row: ReportedComment, action: ModerationAction, done: string) => {
try { try {
await handleCommentReport(row.id, action) await moderateReportedComment(row.id, action)
toast.success(done) toast.success(done)
void list.query.refetch() void list.query.refetch()
} catch (error) { } catch (error) {
@@ -42,92 +44,90 @@ function CommentReportsPage() {
} }
} }
const columns: ColumnDef<CommentReport>[] = [ const columns: ColumnDef<ReportedComment>[] = [
{ {
header: '举报原因', header: '评论',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-56"> <div className="max-w-80">
<div className="font-medium">{row.original.reason}</div> <div className="truncate" title={row.original.body ?? undefined}>
{row.original.message ? ( {row.original.body ?? '—'}
<div className="truncate text-xs text-muted-foreground" title={row.original.message}> </div>
{row.original.message} <div className="text-xs text-muted-foreground">
<code className="font-mono">#{row.original.id}</code>
<span className="ml-2">{row.original.author?.display_name ?? '—'}</span>
</div> </div>
) : null}
</div> </div>
), ),
}, },
{ {
header: '举报评论', header: '举报',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-72"> <Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
<div className="truncate" title={row.original.comment?.body ?? undefined}> {row.original.reports.length}
{row.original.comment?.body ?? `#${row.original.comment_id}(已不存在)`} {row.original.open_reports_count > 0 && (
</div> <span className="text-amber-600 dark:text-amber-400">
<code className="font-mono text-xs text-muted-foreground">#{row.original.comment_id}</code> {row.original.open_reports_count}
</div> </span>
), )}
}, </Button>
{
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: '状态', header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />, cell: ({ row }) => (
<ResourceStateBadge
isBlocked={row.original.is_blocked}
openReports={row.original.open_reports_count}
/>
),
}, },
{ {
header: '举报时间', header: '最近举报',
cell: ({ row }) => cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—', row.original.latest_report_at
? new Date(row.original.latest_report_at).toLocaleString('zh-CN')
: '—',
}, },
{ {
header: '操作', header: '操作',
cell: ({ row }) => cell: ({ row }) => (
row.original.status === 'open' ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
<ConfirmDialog <ConfirmDialog
trigger={<Button size="sm"></Button>} trigger={
title={`隐藏评论 #${row.original.comment_id}`} <Button size="sm" variant="outline">
description="评论将对用户不可见,举报标记为已处理。"
onConfirm={() => act(row.original, 'hide', '已隐藏评论')} </Button>
}
title={`撤销屏蔽评论 #${row.original.id}`}
description="评论将重新对用户可见,举报记录保留。"
onConfirm={() => act(row.original, 'revert', '已撤销屏蔽')}
/>
) : (
row.original.open_reports_count > 0 && (
<>
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`屏蔽评论 #${row.original.id}`}
description="举报成立:评论将对用户不可见(可撤销),其全部待处理举报标记为已处理。"
destructive
onConfirm={() => act(row.original, 'approve', '已屏蔽评论')}
/> />
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
</Button> </Button>
} }
title={`删除评论 #${row.original.comment_id}`} title="驳回该评论的全部待处理举报?"
description="评论将被删除,举报标记为已处理。" description="举报不成立:评论保持原样。"
confirmLabel="删除"
destructive
onConfirm={() => act(row.original, 'delete', '已删除评论')}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="ghost">
</Button>
}
title="驳回该举报?"
description="评论保持原样,举报标记为已驳回。"
confirmLabel="驳回" confirmLabel="驳回"
onConfirm={() => act(row.original, 'dismiss', '已驳回举报')} onConfirm={() => act(row.original, 'reject', '已驳回举报')}
/> />
</>
)
)}
</div> </div>
) : (
<span className="text-xs text-muted-foreground">
{row.original.handled_at
? new Date(row.original.handled_at).toLocaleString('zh-CN')
: '—'}
</span>
), ),
}, },
] ]
@@ -135,18 +135,17 @@ function CommentReportsPage() {
return ( return (
<div> <div>
<PageHeader <PageHeader
title="评论举报" title="被举报评论"
description="用户对评论的举报,可隐藏、删除评论或驳回举报" description="按评论聚合的举报,一次决定处理该评论的全部待处理举报"
actions={ actions={
<Select value={status} onValueChange={setStatus}> <Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32"> <SelectTrigger size="sm" className="w-32">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="pending"></SelectItem>
<SelectItem value="open"></SelectItem> <SelectItem value="blocked"></SelectItem>
<SelectItem value="resolved"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="dismissed"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
@@ -156,10 +155,17 @@ function CommentReportsPage() {
data={list.rows} data={list.rows}
pagination={list.pagination} pagination={list.pagination}
loading={list.loading} loading={list.loading}
emptyText="没有相关举报" emptyText="没有被举报的评论"
onPageChange={list.setPage} onPageChange={list.setPage}
onPageSizeChange={list.setPageSize} onPageSizeChange={list.setPageSize}
/> />
<ReportsSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={`评论 #${detail?.id ?? ''} 的举报`}
description={detail?.body ?? undefined}
reports={detail?.reports ?? []}
/>
</div> </div>
) )
} }
+83 -83
View File
@@ -2,15 +2,17 @@ import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { FileXEventReport } from '@/api/types' import type { ReportedEvent } from '@/api/types'
import type { EventReportAction } from '@/api/modules/filex' import type { ModerationAction } from '@/api/modules/comments'
import { fetchEventReports, handleEventReport } from '@/api/modules/filex' import { fetchReportedEvents, moderateReportedEvent } from '@/api/modules/filex'
import { handleApiError } from '@/lib/errors' import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page' import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table' import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { ReportStatusBadge } from '@/features/reports/report-status-badge' import { ResourceStateBadge } from '@/features/reports/report-status-badge'
import { ReportsSheet } from '@/features/reports/reports-sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Select, Select,
@@ -21,20 +23,21 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
export const Route = createFileRoute('/_authed/event-reports')({ export const Route = createFileRoute('/_authed/event-reports')({
component: EventReportsPage, component: ReportedEventsPage,
}) })
function EventReportsPage() { function ReportedEventsPage() {
const [status, setStatus] = useState<string>('open') const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedEvent | null>(null)
const list = useListPage( const list = useListPage(
'event-reports', 'reported-events',
fetchEventReports, fetchReportedEvents,
status === 'all' ? {} : { status }, status === 'all' ? {} : { status },
) )
const act = async (row: FileXEventReport, action: EventReportAction, done: string) => { const act = async (row: ReportedEvent, action: ModerationAction, done: string) => {
try { try {
await handleEventReport(row.id, action) await moderateReportedEvent(row.id, action)
toast.success(done) toast.success(done)
void list.query.refetch() void list.query.refetch()
} catch (error) { } catch (error) {
@@ -42,102 +45,94 @@ function EventReportsPage() {
} }
} }
const columns: ColumnDef<FileXEventReport>[] = [ const columns: ColumnDef<ReportedEvent>[] = [
{ {
header: '举报原因', header: '事件',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-56"> <div className="max-w-80">
<div className="font-medium">{row.original.reason}</div> <div className="truncate font-medium" title={row.original.title ?? undefined}>
{row.original.message ? ( {row.original.title ?? '—'}
<div className="truncate text-xs text-muted-foreground" title={row.original.message}> </div>
{row.original.message} <div className="text-xs text-muted-foreground">
<code className="font-mono">#{row.original.id}</code>
<span className="ml-2">{row.original.author?.display_name ?? '—'}</span>
</div> </div>
) : null}
</div> </div>
), ),
}, },
{ {
header: '被举报事件', header: '可见性',
cell: ({ row }) => ( cell: ({ row }) => <Badge variant="secondary">{row.original.visibility ?? '—'}</Badge>,
<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: '举报', header: '举报',
cell: ({ row }) => ( cell: ({ row }) => (
<div> <Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
<div className="text-sm">{row.original.reporter?.display_name ?? '—'}</div> {row.original.reports.length}
<code className="font-mono text-xs text-muted-foreground"> {row.original.open_reports_count > 0 && (
{row.original.reporter?.uid ?? ''} <span className="text-amber-600 dark:text-amber-400">
</code> {row.original.open_reports_count}
</div> </span>
)}
</Button>
), ),
}, },
{ {
header: '状态', header: '状态',
cell: ({ row }) => <ReportStatusBadge status={row.original.status} />, cell: ({ row }) => (
<ResourceStateBadge
isBlocked={row.original.is_blocked}
openReports={row.original.open_reports_count}
/>
),
}, },
{ {
header: '举报时间', header: '最近举报',
cell: ({ row }) => cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—', row.original.latest_report_at
? new Date(row.original.latest_report_at).toLocaleString('zh-CN')
: '—',
}, },
{ {
header: '操作', header: '操作',
cell: ({ row }) => cell: ({ row }) => (
row.original.status === 'open' ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
<ConfirmDialog <ConfirmDialog
trigger={<Button size="sm"></Button>} trigger={
title={`隐藏事件 #${row.original.event_id}`} <Button size="sm" variant="outline">
description="事件将对用户不可见,举报标记为已处理。"
onConfirm={() => act(row.original, 'hide', '已隐藏事件')} </Button>
}
title={`撤销屏蔽事件「${row.original.title ?? row.original.id}」?`}
description="事件将重新对用户可见,举报记录保留。"
onConfirm={() => act(row.original, 'revert', '已撤销屏蔽')}
/>
) : (
row.original.open_reports_count > 0 && (
<>
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`屏蔽事件「${row.original.title ?? row.original.id}」?`}
description="举报成立:事件将对用户不可见(可撤销),其全部待处理举报标记为已处理。"
destructive
onConfirm={() => act(row.original, 'approve', '已屏蔽事件')}
/> />
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
</Button> </Button>
} }
title={`删除事件 #${row.original.event_id}`} title="驳回该事件的全部待处理举报?"
description="事件将被删除,举报标记为已处理。" 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="驳回" confirmLabel="驳回"
onConfirm={() => act(row.original, 'dismiss', '已驳回举报')} onConfirm={() => act(row.original, 'reject', '已驳回举报')}
/> />
</>
)
)}
</div> </div>
) : (
<span className="text-xs text-muted-foreground">
{row.original.handled_at
? new Date(row.original.handled_at).toLocaleString('zh-CN')
: '—'}
</span>
), ),
}, },
] ]
@@ -145,18 +140,17 @@ function EventReportsPage() {
return ( return (
<div> <div>
<PageHeader <PageHeader
title="事件举报" title="被举报事件"
description="用户对事迹档案事件的举报,可隐藏、删除事件或驳回举报" description="按事件聚合的举报,一次决定处理该事件的全部待处理举报"
actions={ actions={
<Select value={status} onValueChange={setStatus}> <Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32"> <SelectTrigger size="sm" className="w-32">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="pending"></SelectItem>
<SelectItem value="open"></SelectItem> <SelectItem value="blocked"></SelectItem>
<SelectItem value="resolved"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="dismissed"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
@@ -166,10 +160,16 @@ function EventReportsPage() {
data={list.rows} data={list.rows}
pagination={list.pagination} pagination={list.pagination}
loading={list.loading} loading={list.loading}
emptyText="没有相关举报" emptyText="没有被举报的事件"
onPageChange={list.setPage} onPageChange={list.setPage}
onPageSizeChange={list.setPageSize} onPageSizeChange={list.setPageSize}
/> />
<ReportsSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={`事件「${detail?.title ?? detail?.id ?? ''}」的举报`}
reports={detail?.reports ?? []}
/>
</div> </div>
) )
} }