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