From c22b23cb8c4e1f93fc14625140e00b04940906a0 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 29 May 2026 16:48:00 +0800 Subject: [PATCH] feat: add report list page --- contracts/admin-openapi.json | 12 + src/app/navigation/menu.js | 3 + src/app/permissions.ts | 2 + src/features/operations/api.ts | 9 + src/features/operations/operations.module.css | 50 ++++ .../operations/pages/ReportListPage.jsx | 260 ++++++++++++++++++ src/features/operations/routes.js | 8 + src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 28 ++ src/shared/api/types.ts | 37 +++ 10 files changed, 417 insertions(+) create mode 100644 src/features/operations/pages/ReportListPage.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index b2071e6..09dbbe4 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1471,6 +1471,18 @@ "x-permissions": ["coin-ledger:view"] } }, + "/admin/operations/reports": { + "get": { + "operationId": "listReports", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "report:view", + "x-permissions": ["report:view"] + } + }, "/admin/payment/recharge-bills": { "get": { "operationId": "listRechargeBills", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 7600599..85fde80 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -6,6 +6,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import CategoryOutlined from "@mui/icons-material/CategoryOutlined"; import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined"; +import FlagOutlined from "@mui/icons-material/FlagOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; import HubOutlined from "@mui/icons-material/HubOutlined"; @@ -44,6 +45,7 @@ const iconMap = { dashboard: DashboardOutlined, event_available: EventAvailableOutlined, explore: MapOutlined, + flag: FlagOutlined, gift: CardGiftcardOutlined, inventory: Inventory2Outlined, leaderboard: LeaderboardOutlined, @@ -144,6 +146,7 @@ export const fallbackNavigation = [ children: [ routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), + routeNavItem("operation-reports", { icon: FlagOutlined }), routeNavItem("lucky-gift", { icon: RedeemOutlined }), ], }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 6c5ab18..6b6b29d 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -28,6 +28,7 @@ export const PERMISSIONS = { coinLedgerView: "coin-ledger:view", coinAdjustmentView: "coin-adjustment:view", coinAdjustmentCreate: "coin-adjustment:create", + reportView: "report:view", paymentBillView: "payment-bill:view", paymentProductView: "payment-product:view", paymentProductCreate: "payment-product:create", @@ -160,6 +161,7 @@ export const MENU_CODES = { operations: "operations", operationCoinLedger: "operation-coin-ledger", operationCoinAdjustment: "operation-coin-adjustment", + operationReports: "operation-reports", luckyGift: "lucky-gift", activities: "activities", dailyTaskList: "daily-task-list", diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index 8494d9e..66ffa16 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -8,6 +8,7 @@ import type { CoinLedgerEntryDto, CoinLedgerUserDto, PageQuery, + ReportDto, } from "@/shared/api/types"; export function listCoinLedger(query: PageQuery = {}): Promise> { @@ -26,6 +27,14 @@ export function listCoinAdjustments(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listReports; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listReports), { + method: endpoint.method, + query, + }); +} + export function lookupCoinAdjustmentTarget(userId: string): Promise { const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget; return apiRequest(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), { diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index ea8d9c4..8c76bd6 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -185,6 +185,56 @@ background: var(--bg-card-strong); } +.reportTargetType { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--text-primary); + font-weight: 700; + white-space: nowrap; +} + +.reportReason { + display: -webkit-box; + overflow: hidden; + color: var(--text-primary); + line-height: 1.55; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.reportImages { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.reportImage, +.reportImageCount { + display: inline-flex; + flex: 0 0 auto; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + text-decoration: none; +} + +.reportImage img { + width: 100%; + height: 100%; + object-fit: cover; +} + @media (max-width: 640px) { .detailGrid { grid-template-columns: 1fr; diff --git a/src/features/operations/pages/ReportListPage.jsx b/src/features/operations/pages/ReportListPage.jsx new file mode 100644 index 0000000..ef241d1 --- /dev/null +++ b/src/features/operations/pages/ReportListPage.jsx @@ -0,0 +1,260 @@ +import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; +import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; +import { useMemo, useState } from "react"; +import { listReports } from "@/features/operations/api"; +import styles from "@/features/operations/operations.module.css"; +import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { + AdminFilterResetButton, + AdminListBody, + AdminListPage, + AdminListToolbar, +} from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; +import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; +import { formatMillis } from "@/shared/utils/time.js"; + +const pageSize = 50; + +const targetTypeOptions = [ + { label: "全部目标", value: "" }, + { label: "用户", value: "user" }, + { label: "房间", value: "room" }, +]; + +const reportTypeOptions = [ + { label: "全部类型", value: "" }, + { label: "政治", value: "politics" }, + { label: "色情", value: "pornography" }, + { label: "血腥暴力", value: "graphic_violence" }, + { label: "言语辱骂", value: "verbal_abuse" }, + { label: "欺诈", value: "fraud" }, + { label: "违法内容", value: "illegal_content" }, + { label: "未成年人", value: "minors" }, + { label: "线下交易", value: "in_person_transactions" }, + { label: "其他", value: "other" }, +]; + +const columns = [ + { + key: "targetType", + label: "举报目标", + render: (report) => , + width: "minmax(120px, 0.55fr)", + }, + { + key: "target", + label: "举报信息", + render: (report) => , + width: "minmax(300px, 1.3fr)", + }, + { + key: "reportType", + label: "举报类型", + render: (report) => reportTypeLabel(report.reportType), + width: "minmax(150px, 0.7fr)", + }, + { + key: "reason", + label: "举报理由", + render: (report) => {report.reason || "-"}, + width: "minmax(220px, 1fr)", + }, + { + key: "imageUrls", + label: "举报图片", + render: (report) => , + width: "minmax(180px, 0.8fr)", + }, + { + key: "createdAtMs", + label: "提交时间", + render: (report) => formatMillis(report.createdAtMs), + width: "minmax(170px, 0.75fr)", + }, +]; + +export function ReportListPage() { + const [page, setPage] = useState(1); + const [keyword, setKeyword] = useState(""); + const [targetType, setTargetType] = useState(""); + const [reportType, setReportType] = useState(""); + const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" }); + + const filters = useMemo( + () => ({ + end_at_ms: timeRange.endMs || "", + keyword, + report_type: reportType, + start_at_ms: timeRange.startMs || "", + target_type: targetType, + }), + [keyword, reportType, targetType, timeRange.endMs, timeRange.startMs], + ); + const query = usePaginatedQuery({ + errorMessage: "获取举报列表失败", + fetcher: listReports, + filters, + page, + pageSize, + queryKey: ["reports", filters, page], + }); + const data = query.data || { items: [], total: 0, page, pageSize }; + const items = data.items || []; + const total = data.total || 0; + + const changeFilter = (setter) => (value) => { + setter(value); + setPage(1); + }; + const changeTimeRange = (nextRange) => { + setTimeRange(nextRange); + setPage(1); + }; + const resetFilters = () => { + setKeyword(""); + setTargetType(""); + setReportType(""); + setTimeRange({ endMs: "", startMs: "" }); + setPage(1); + }; + + const tableColumns = columns.map((column) => { + if (column.key === "target") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "长 ID / 短 ID / 昵称", + value: keyword, + onChange: changeFilter(setKeyword), + }), + }; + } + if (column.key === "targetType") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: targetTypeOptions, + value: targetType, + onChange: changeFilter(setTargetType), + }), + }; + } + if (column.key === "reportType") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: reportTypeOptions, + value: reportType, + onChange: changeFilter(setReportType), + }), + }; + } + return column; + }); + + return ( + + + + + + } + /> + + + 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={(report) => report.reportId} + /> + + + + ); +} + +function TargetTypeLabel({ report }) { + const isRoom = report.targetType === "room"; + return ( + + {isRoom ? : } + {isRoom ? "房间" : "用户"} + + ); +} + +function TargetCell({ report }) { + const target = report.target || {}; + if (report.targetType === "room") { + const room = target.room || {}; + return ( +
+ + {room.coverUrl ? : } + + + {room.title || "-"} + {targetMeta(room.roomShortId, room.roomId || report.roomId)} + +
+ ); + } + const user = target.user || {}; + return ( +
+ + {user.avatar ? : } + + + {user.username || "-"} + + {targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)} + + +
+ ); +} + +function EvidenceImages({ imageUrls = [] }) { + const urls = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : []; + if (!urls.length) { + return "-"; + } + return ( +
+ {urls.slice(0, 4).map((url) => ( + + + + ))} + {urls.length > 4 ? +{urls.length - 4} : null} +
+ ); +} + +function targetMeta(shortId, longId) { + return `短ID ${shortId || "-"} · 长ID ${longId || "-"}`; +} + +function reportTypeLabel(value) { + return reportTypeOptions.find((item) => item.value === value)?.label || value || "-"; +} diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js index 5130c67..e23f83a 100644 --- a/src/features/operations/routes.js +++ b/src/features/operations/routes.js @@ -17,4 +17,12 @@ export const operationsRoutes = [ path: "/operations/coin-adjustments", permission: PERMISSIONS.coinAdjustmentView, }, + { + label: "举报列表", + loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage), + menuCode: MENU_CODES.operationReports, + pageKey: "operation-reports", + path: "/operations/reports", + permission: PERMISSIONS.reportView, + }, ]; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 64aeb9a..63d4389 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -131,6 +131,7 @@ export const API_OPERATIONS = { listRegionBlocks: "listRegionBlocks", listRegions: "listRegions", listRegistrationRewardClaims: "listRegistrationRewardClaims", + listReports: "listReports", listResourceGrants: "listResourceGrants", listResourceGroups: "listResourceGroups", listResources: "listResources", @@ -1028,6 +1029,13 @@ export const API_ENDPOINTS: Record = { permission: "registration-reward:view", permissions: ["registration-reward:view"] }, + listReports: { + method: "GET", + operationId: API_OPERATIONS.listReports, + path: "/v1/admin/operations/reports", + permission: "report:view", + permissions: ["report:view"] + }, listResourceGrants: { method: "GET", operationId: API_OPERATIONS.listResourceGrants, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 0bf56a1..fab8d9d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1012,6 +1012,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/operations/reports": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReports"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/payment/recharge-bills": { parameters: { query?: never; @@ -3785,6 +3801,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listReports: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listRechargeBills: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index e04ba66..60e896f 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -245,6 +245,43 @@ export interface CoinAdjustmentCreateDto { user: CoinLedgerUserDto; } +export interface ReportUserDto { + avatar?: string; + defaultDisplayUserId?: string; + displayUserId?: string; + userId?: string; + username?: string; +} + +export interface ReportRoomDto { + coverUrl?: string; + roomId?: string; + roomShortId?: string; + title?: string; +} + +export interface ReportTargetDto { + room?: ReportRoomDto; + type: "user" | "room" | string; + user?: ReportUserDto; +} + +export interface ReportDto { + createdAtMs?: number; + imageUrls?: string[]; + reason?: string; + reportId: string; + reporterUserId?: string; + reportType?: string; + requestId?: string; + roomId?: string; + status?: string; + target: ReportTargetDto; + targetType: "user" | "room" | string; + updatedAtMs?: number; + userId?: string; +} + export interface RechargeBillDto { appCode?: string; coinAmount: number;