From 902e71ee62ec89206a190565993d54153113d621 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 19 Sep 2026 22:00:17 +0800 Subject: [PATCH] feat(admin): rebuild ban page to mirror the reference theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename the users.php submenu to 举报&禁封申诉 and the page heading to 用户禁封申诉及用户举报管理 - merge report tickets and ban appeals into one Zibll-style queue: type filter buttons (全部/禁封申诉/用户举报), status subsubsub (全部/待处理/已批准/已拒绝), search box, sortable widefat table with the same column set (状态/类型/提交用户/详情/提交时间/更新时间), inline process forms and 20-per-page pagination - report process form shows the reported target, restriction status and an enforcement action select; appeal form offers approve/reject radios with a message to the user - move restriction enforcement into ReportCenter::apply_action so REST and the native page share one implementation; add get_report_row() - drop the old three-tab rendering (reports/appeals tabs) and fix all notice/mail/SPA links to the merged page --- assets/admin/src/constants/sections/users.js | 2 +- assets/admin/src/router/index.js | 2 +- src/Admin/Native/UsersMenus.php | 195 ++------ src/Admin/Native/UsersMenusTickets.php | 452 ++++++++++++++++++ src/Admin/UserControllerReportOperations.php | 55 +-- src/Messages/ReportCenter.php | 81 ++++ src/Settings/MailNotifications.php | 2 +- .../report-ban-contract.test.cjs | 54 ++- 8 files changed, 626 insertions(+), 217 deletions(-) create mode 100644 src/Admin/Native/UsersMenusTickets.php diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js index 30a6aeda..83776da1 100644 --- a/assets/admin/src/constants/sections/users.js +++ b/assets/admin/src/constants/sections/users.js @@ -141,7 +141,7 @@ export const usersSection = { iconClass: 'fa fas fa-gavel', canonicalDomain: 'users', section: 'ban', - nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=reports', + nativeAdminPath: 'users.php?page=aksrt_user_ban', }), ], } diff --git a/assets/admin/src/router/index.js b/assets/admin/src/router/index.js index c981cb27..1d827da7 100644 --- a/assets/admin/src/router/index.js +++ b/assets/admin/src/router/index.js @@ -83,7 +83,7 @@ function createSectionRedirectRoutes() { const NATIVE_OPS_REDIRECTS = [ { path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' }, { path: '/users/referral-codes', title: '推广码运营', nativeAdminPath: 'users.php?page=aksrt_referral_codes' }, - { path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=reports' }, + { path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban' }, ] const routes = [ diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 09df64dc..1c74dcef 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -12,6 +12,7 @@ class UsersMenus { use UsersMenusVerify; use UsersMenusVip; use UsersMenusReferral; + use UsersMenusTickets; public const CAPABILITY = 'manage_options'; @@ -81,8 +82,8 @@ class UsersMenus { ); add_submenu_page( 'users.php', - __('举报&禁封', 'aksrt'), - __('举报&禁封', 'aksrt'), + __('举报&禁封申诉', 'aksrt'), + __('举报&禁封申诉', 'aksrt'), self::CAPABILITY, 'aksrt_user_ban', [$this, 'render_ban_page'] @@ -112,7 +113,7 @@ class UsersMenus { if ($page !== 'aksrt_user_ban' && class_exists('\AKSRT\Messages\ReportCenter')) { $pending_reports = (int) \AKSRT\Messages\ReportCenter::get_instance()->get_pending_count(); if ($pending_reports > 0) { - $url = admin_url('users.php?page=aksrt_user_ban&tab=reports&status=pending'); + $url = admin_url('users.php?page=aksrt_user_ban&status=pending'); echo '

' . esc_html__('举报待处理', 'aksrt') . '

'; echo '

' . esc_html(sprintf( /* translators: %d count */ @@ -131,7 +132,7 @@ class UsersMenus { 'fields' => 'ID', ])); if ($pending_appeals > 0) { - $url = admin_url('users.php?page=aksrt_user_ban&tab=appeals&status=pending'); + $url = admin_url('users.php?page=aksrt_user_ban&type=appeal&status=pending'); echo '

' . esc_html__('禁封申诉待处理', 'aksrt') . '

'; echo '

' . esc_html(sprintf(__('您有 %d 条禁封申诉待处理', 'aksrt'), $pending_appeals)) . '

'; echo '

' . esc_html__('立即处理', 'aksrt') . '

'; @@ -283,87 +284,27 @@ class UsersMenus { public function render_ban_page(): void { $this->ensure_cap(); - echo '

' . esc_html__('举报&禁封', 'aksrt') . '

'; + echo '

' . esc_html__('用户禁封申诉及用户举报管理', 'aksrt') . '

'; $this->flash(); - $tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'reports'; + $tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets'; + if (!in_array($tab, ['tickets', 'restrictions'], true)) { + $tab = 'tickets'; + } + echo ''; if ($tab === 'restrictions') { $this->render_restrictions_tab(); - } elseif ($tab === 'appeals') { - $this->render_appeals_tab(); } else { - $this->render_reports_tab(); + $this->render_tickets_tab(); } echo '
'; } - private function render_reports_tab(): void { - if (!class_exists('\AKSRT\Messages\ReportCenter')) { - echo '

' . esc_html__('举报中心未初始化。', 'aksrt') . '

'; - return; - } - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; - $svc = \AKSRT\Messages\ReportCenter::get_instance(); - $counts = $svc->get_status_counts(); - - echo '

' . esc_html(sprintf( - /* translators: 1 pending 2 total */ - __('待处理 %1$d / 共 %2$d', 'aksrt'), - (int) ($counts['pending'] ?? 0), - (int) ($counts['total'] ?? 0) - )) . '

'; - - echo '
'; - echo ''; - echo ' '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - - $items = $svc->get_reports(['status' => $status, 'limit' => 50, 'offset' => 0]); - echo ''; - echo ''; - echo ''; - if (!$items) { - echo ''; - } - foreach ($items as $row) { - $id = (int) ($row['id'] ?? 0); - $st = (string) ($row['status'] ?? ''); - $rid = (int) ($row['reporter_id'] ?? 0); - $ru = $rid > 0 ? get_userdata($rid) : null; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
ID' . esc_html__('举报人', 'aksrt') . '' . esc_html__('目标', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无举报', 'aksrt') . '
' . esc_html((string) $id) . '' . ($ru ? esc_html($ru->display_name) : esc_html((string) $rid)) . '' . esc_html((string) ($row['target_type'] ?? '') . ' #' . (string) ($row['target_id'] ?? '')) . '' . esc_html((string) ($row['reason'] ?? '')) . '' . esc_html($st) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_update_report_' . $id); - echo ''; - echo ''; - submit_button(__('更新', 'aksrt'), 'small', '', false); - echo '
'; - } - private function render_restrictions_tab(): void { if (!class_exists('\AKSRT\User\Restriction')) { echo '

' . esc_html__('限制模块未初始化。', 'aksrt') . '

'; @@ -436,12 +377,29 @@ class UsersMenus { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } - $id = isset($_POST['report_id']) ? (int) $_POST['report_id'] : 0; - $new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : ''; - $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; + $id = isset($_POST['report_id']) ? (int) $_POST['report_id'] : 0; + $new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : ''; + $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; + $action_taken = isset($_POST['action_taken']) ? sanitize_key((string) wp_unslash($_POST['action_taken'])) : 'none'; check_admin_referer('aksrt_users_update_report_' . $id); - $ok = $id > 0 && class_exists('\AKSRT\Messages\ReportCenter') && \AKSRT\Messages\ReportCenter::get_instance()->update_status($id, $new_status, $note); - $this->redirect('aksrt_user_ban', $ok ? 'report_updated' : 'report_failed', ['tab' => 'reports']); + + $ok = false; + if ($id > 0 && class_exists('\AKSRT\Messages\ReportCenter')) { + $svc = \AKSRT\Messages\ReportCenter::get_instance(); + $ok = $svc->update_status($id, $new_status, $note); + if ($ok && $action_taken !== '' && $action_taken !== 'none') { + $row = $svc->get_report_row($id); + if ($row) { + $svc->apply_action( + sanitize_key((string) $row['target_type']), + (int) $row['target_id'], + $action_taken, + (string) ($row['reason'] ?? '') + ); + } + } + } + $this->redirect('aksrt_user_ban', $ok ? 'report_updated' : 'report_failed', ['tab' => 'tickets']); } public function handle_add_restriction(): void { @@ -471,90 +429,17 @@ class UsersMenus { $this->redirect('aksrt_user_ban', $ok ? 'restriction_removed' : 'restriction_failed', ['tab' => 'restrictions']); } - - private function render_appeals_tab(): void { - if (!class_exists('\AKSRT\User\BanAppeal')) { - echo '

' . esc_html__('申诉模块未初始化。', 'aksrt') . '

'; - return; - } - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; - if (!in_array($status, ['pending', 'approved', 'rejected', 'all'], true)) { - $status = 'pending'; - } - - echo '
'; - echo ''; - echo ' '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - - $users = get_users([ - 'meta_key' => \AKSRT\User\BanAppeal::META_STATUS, - 'meta_compare' => 'EXISTS', - 'number' => 100, - 'fields' => ['ID', 'display_name', 'user_login', 'user_email'], - 'orderby' => 'ID', - 'order' => 'DESC', - ]); - - echo ''; - echo ''; - echo ''; - $shown = 0; - foreach ($users as $u) { - $uid = (int) $u->ID; - $st = \AKSRT\User\BanAppeal::get_status($uid); - if ($st === '') { - continue; - } - if ($status !== 'all' && $st !== $status) { - continue; - } - $payload = \AKSRT\User\BanAppeal::get_payload($uid); - $shown++; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - if ($shown < 1) { - echo ''; - } - echo '
UID' . esc_html__('用户', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('说明', 'aksrt') . '' . esc_html__('提交时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html((string) $uid) . '' . esc_html($u->display_name . ' (' . $u->user_login . ')') . '
' . esc_html($u->user_email) . '
' . esc_html($st) . '' . esc_html((string) ($payload['reason_key'] ?? '—')) . '' . esc_html((string) ($payload['reason'] ?? '')) . '' . esc_html((string) ($payload['time'] ?? '—')) . ''; - if ($st === 'pending') { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_process_appeal_' . $uid); - echo ''; - echo ''; - echo ''; - echo '
'; - } else { - echo '—'; - } - echo '
' . esc_html__('暂无申诉', 'aksrt') . '
'; - } - public function handle_process_appeal(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } - $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; + $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; $decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : ''; - $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; + $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; check_admin_referer('aksrt_users_process_appeal_' . $user_id); if ($user_id <= 0 || !in_array($decision, ['approve', 'reject'], true) || !class_exists('\AKSRT\User\BanAppeal')) { - $this->redirect('aksrt_user_ban', 'appeal_failed', ['tab' => 'appeals']); + $this->redirect('aksrt_user_ban', 'appeal_failed', ['tab' => 'tickets', 'type' => 'appeal']); } $status = $decision === 'approve' ? 'approved' : 'rejected'; @@ -564,7 +449,7 @@ class UsersMenus { $payload['admin_note'] = $note; $payload['handled_at'] = current_time('Y-m-d H:i'); $payload['handled_by'] = get_current_user_id(); - $payload['decision'] = $status; + $payload['decision'] = $status; update_user_meta($user_id, \AKSRT\User\BanAppeal::META_PAYLOAD, $payload); $history = get_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, true); @@ -598,7 +483,7 @@ class UsersMenus { $this->redirect( 'aksrt_user_ban', $decision === 'approve' ? 'appeal_approved' : 'appeal_rejected', - ['tab' => 'appeals'] + ['tab' => 'tickets', 'type' => 'appeal'] ); } diff --git a/src/Admin/Native/UsersMenusTickets.php b/src/Admin/Native/UsersMenusTickets.php new file mode 100644 index 00000000..80f448f6 --- /dev/null +++ b/src/Admin/Native/UsersMenusTickets.php @@ -0,0 +1,452 @@ +get_reports(['limit' => 500]); + foreach ((array) $reports as $r) { + $st = sanitize_key((string) ($r['status'] ?? '')); + $norm = $st === 'reviewing' ? 'pending' : $st; + if (!in_array($norm, ['pending', 'resolved', 'dismissed'], true)) { + continue; + } + if ($status !== '' && $norm !== $status) { + continue; + } + if ($send_user > 0 && (int) ($r['reporter_id'] ?? 0) !== $send_user) { + continue; + } + $hay = (string) ($r['reason'] ?? '') . ' ' . (string) ($r['category'] ?? '') . ' ' . (string) ($r['target_type'] ?? '') . ' #' . (string) ($r['target_id'] ?? ''); + if ($s !== '' && mb_stripos($hay, $s) === false) { + continue; + } + $rows[] = [ + 'kind' => 'report', + 'id' => (int) ($r['id'] ?? 0), + 'user_id' => (int) ($r['reporter_id'] ?? 0), + 'status' => $norm, + 'raw_status' => $st, + 'time' => (string) ($r['created_at'] ?? ''), + 'updated' => (string) ($r['updated_at'] ?? ''), + 'payload' => $r, + ]; + } + } + + if ($type !== 'report' && class_exists('\AKSRT\User\BanAppeal')) { + $users = get_users([ + 'meta_key' => \AKSRT\User\BanAppeal::META_STATUS, + 'meta_compare' => 'EXISTS', + 'number' => 500, + 'fields' => ['ID'], + 'orderby' => 'ID', + 'order' => 'DESC', + ]); + foreach ($users as $u) { + $uid = (int) $u->ID; + $st = \AKSRT\User\BanAppeal::get_status($uid); + if ($st === '' || !in_array($st, ['pending', 'approved', 'rejected'], true)) { + continue; + } + $norm = $st === 'approved' ? 'resolved' : ($st === 'rejected' ? 'dismissed' : 'pending'); + if ($status !== '' && $norm !== $status) { + continue; + } + if ($send_user > 0 && $uid !== $send_user) { + continue; + } + $payload = \AKSRT\User\BanAppeal::get_payload($uid); + $hay = (string) ($payload['reason'] ?? '') . ' ' . (string) ($payload['reason_key'] ?? ''); + if ($s !== '' && mb_stripos($hay, $s) === false) { + continue; + } + $rows[] = [ + 'kind' => 'appeal', + 'id' => $uid, + 'user_id' => $uid, + 'status' => $norm, + 'raw_status' => $st, + 'time' => (string) ($payload['time'] ?? ''), + 'updated' => (string) ($payload['handled_at'] ?? ''), + 'payload' => $payload, + ]; + } + } + + $weights = ['pending' => 0, 'resolved' => 1, 'dismissed' => 2]; + usort($rows, static function (array $a, array $b) use ($orderby, $order, $weights): int { + if ($orderby === 'status') { + $cmp = ($weights[$a['status']] ?? 9) <=> ($weights[$b['status']] ?? 9); + } elseif ($orderby === 'type') { + $cmp = strcmp($a['kind'], $b['kind']); + } else { + $cmp = strcmp($a['time'], $b['time']); + } + return $order === 'ASC' ? $cmp : -$cmp; + }); + + return $rows; + } + + public function render_tickets_tab(): void { + $type = isset($_GET['type']) ? sanitize_key((string) wp_unslash($_GET['type'])) : ''; + $type = in_array($type, ['report', 'appeal'], true) ? $type : ''; + $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; + $status = in_array($status, ['pending', 'resolved', 'dismissed'], true) ? $status : ''; + $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; + $send_user = isset($_GET['send_user']) ? (int) $_GET['send_user'] : 0; + $paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; + $orderby = isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : 'time'; + $orderby = in_array($orderby, ['time', 'status', 'type'], true) ? $orderby : 'time'; + $order = (isset($_GET['order']) && strtoupper((string) $_GET['order']) === 'ASC') ? 'ASC' : 'DESC'; + + $action_url = admin_url('users.php?page=aksrt_user_ban'); + $page_url = add_query_arg(array_filter([ + 'tab' => 'tickets', + 'type' => $type, + 'status' => $status, + 's' => $s, + 'send_user' => $send_user, + ], static function ($v) { return $v !== '' && $v !== 0; }), $action_url); + + $rows = $this->collect_ticket_rows($type, $status, $s, $send_user, $orderby, $order); + $total = count($rows); + $pages = max(1, (int) ceil($total / self::TICKETS_PER_PAGE)); + $paged = min($paged, $pages); + $tickets = array_slice($rows, ($paged - 1) * self::TICKETS_PER_PAGE, self::TICKETS_PER_PAGE); + + // 类型筛选按钮组(对齐参考主题:全部 / 禁封申诉 / 用户举报) + echo '
'; + foreach (['' => __('全部', 'aksrt'), 'appeal' => __('禁封申诉', 'aksrt'), 'report' => __('用户举报', 'aksrt')] as $t => $label) { + $url = $t === '' + ? remove_query_arg(['s', 'type', 'action', 'id'], $page_url) + : add_query_arg('type', $t, remove_query_arg(['s', 'action', 'id'], $page_url)); + printf('%s ', $type === $t ? ' button-primary' : '', esc_url($url), esc_html($label)); + } + echo '
'; + + // 状态 subsubsub(对齐参考主题:全部 | 待处理 | 已批准 | 已拒绝) + echo '
    '; + $status_links = [ + '' => __('全部', 'aksrt'), + 'pending' => __('待处理', 'aksrt'), + 'resolved' => __('已批准', 'aksrt'), + 'dismissed'=> __('已拒绝', 'aksrt'), + ]; + $i = 0; + foreach ($status_links as $key => $label) { + $url = $key === '' ? remove_query_arg(['s', 'status'], $page_url) : add_query_arg('status', $key, remove_query_arg('s', $page_url)); + $sep = ++$i < count($status_links) ? ' |' : ''; + printf('
  • %s%s
  • ', esc_url($url), esc_html($label), esc_html($sep)); + } + echo '
'; + + // 搜索(对齐参考主题:右侧搜索记录表单) + echo '
'; + echo '
'; + echo ''; + if ($type !== '') { + echo ''; + } + echo ''; + submit_button(__('提交', 'aksrt'), 'primary', '', false); + echo '
'; + + if ($s !== '') { + echo '
"' . esc_html($s) . '" ' . esc_html__('的搜索结果', 'aksrt') . '
'; + } + + echo '
'; + echo ''; + echo ''; + + // 表头(对齐参考主题:状态/类型/提交用户/详情/提交时间/更新时间 + 排序箭头) + $theads = [ + ['width' => '5%', 'orderby' => 'status', 'name' => __('状态', 'aksrt')], + ['width' => '5%', 'orderby' => 'type', 'name' => __('类型', 'aksrt')], + ['width' => '5%', 'orderby' => '', 'name' => __('提交用户', 'aksrt')], + ['width' => '18%', 'orderby' => '', 'name' => __('详情', 'aksrt')], + ['width' => '6%', 'orderby' => 'time', 'name' => __('提交时间', 'aksrt')], + ['width' => '6%', 'orderby' => 'updated','name' => __('更新时间', 'aksrt')], + ]; + foreach ($theads as $thead) { + $orderby_html = ''; + if ($thead['orderby'] !== '') { + $orderby_url = add_query_arg('orderby', $thead['orderby'], $page_url); + $orderby_html = ''; + $orderby_html .= ''; + $orderby_html .= ''; + $orderby_html .= ''; + } + echo ''; + } + echo ''; + + if (!$tickets) { + echo ''; + } + + $process_action = isset($_GET['action']) ? sanitize_key((string) wp_unslash($_GET['action'])) : ''; + $process_id = isset($_GET['id']) ? (string) wp_unslash($_GET['id']) : ''; + + foreach ($tickets as $row) { + $this->render_ticket_row($row, $page_url); + if ($process_action === 'process' && $process_id === (string) $row['kind'] . '-' . (string) $row['id']) { + $this->render_ticket_process_form($row, $action_url); + } + } + + echo '
' . esc_html($thead['name']) . $orderby_html . '
' . esc_html__('暂无申请记录', 'aksrt') . '
'; + + $this->pagination($pages, $paged); + } + + private function render_ticket_row(array $row, string $page_url): void { + $kind = $row['kind']; + $status = $row['status']; + $user_id = (int) $row['user_id']; + $url = add_query_arg(['action' => 'process', 'id' => $kind . '-' . $row['id']], $page_url); + + // 状态列(对齐参考主题:待处理→立即处理按钮 / 已批准 / 处理完成 / 已拒绝) + if ($status === 'pending') { + $status_html = '' . esc_html__('立即处理', 'aksrt') . ''; + } elseif ($status === 'resolved') { + $status_html = $kind === 'appeal' + ? '' . esc_html__('已批准', 'aksrt') . '' + : '' . esc_html__('处理完成', 'aksrt') . ''; + } else { + $status_html = '' . esc_html__('已拒绝', 'aksrt') . ''; + } + + // 类型列(对齐参考主题:不良举报红 / 禁封申诉绿) + $type_html = $kind === 'report' + ? '' . esc_html__('不良举报', 'aksrt') . '' + : '' . esc_html__('禁封申诉', 'aksrt') . ''; + + // 提交用户列(显示名链接 + 管理|查看 row-actions) + $user = $user_id > 0 ? get_userdata($user_id) : null; + if ($user) { + $user_html = '' . esc_html($user->display_name) . ''; + $user_html .= '
'; + $user_html .= '' . esc_html__('管理', 'aksrt') . ' | '; + $home = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user_id) : get_author_posts_url($user_id); + $user_html .= '' . esc_html__('查看', 'aksrt') . ''; + $user_html .= '
'; + } else { + $user_html = esc_html__('未登录用户', 'aksrt'); + } + + $desc_html = $kind === 'report' ? $this->ticket_report_desc_html($row['payload']) : $this->ticket_appeal_desc_html($row['payload'], (int) $row['id']); + + echo ''; + echo '' . $status_html . ''; + echo '' . $type_html . ''; + echo '' . $user_html . ''; + echo '
' . $desc_html . '
'; + echo '' . esc_html((string) $row['time']) . ''; + echo '' . esc_html((string) ($row['updated'] !== '' ? $row['updated'] : '—')) . ''; + echo ''; + } + + /** 举报行详情(对齐参考主题:举报目标红字 + 原因 + 分类) */ + private function ticket_report_desc_html(array $r): string { + $target_type = sanitize_key((string) ($r['target_type'] ?? '')); + $target_id = (int) ($r['target_id'] ?? 0); + $link = $this->ticket_target_link($target_type, $target_id); + + $html = '
' . esc_html(sprintf(__('举报目标:%s', 'aksrt'), $link !== '' ? $link : $target_type . ' #' . $target_id)) . '
'; + $html .= '
' . esc_html(sprintf(__('举报原因:%s', 'aksrt'), (string) ($r['reason'] ?? ''))) . '
'; + if (!empty($r['category'])) { + $html .= '
' . esc_html(sprintf(__('分类:%s', 'aksrt'), (string) $r['category'])) . '
'; + } + return $html; + } + + /** 申诉行详情(对齐参考主题:禁封状态/原因/起止时间,或已解封) */ + private function ticket_appeal_desc_html(array $payload, int $user_id): string { + $ban_html = $this->ticket_ban_info_html($user_id); + $html = $ban_html; + if (!empty($payload['reason'])) { + $html .= '
' . esc_html(sprintf(__('申诉说明:%s', 'aksrt'), (string) $payload['reason'])) . '
'; + } + return $html; + } + + /** 用户当前限制状态摘要(对齐参考主题禁封信息展示) */ + private function ticket_ban_info_html(int $user_id): string { + if ($user_id <= 0 || !class_exists('\AKSRT\User\Restriction')) { + return ''; + } + $svc = \AKSRT\User\Restriction::get_instance(); + $ban = $svc->get_active_ban($user_id); + + if ($ban) { + $end = !empty($ban['expires_at']) ? (string) $ban['expires_at'] : __('永久', 'aksrt'); + $html = '
' . esc_html(sprintf(__('禁封状态:%s', 'aksrt'), __('封禁中(禁止登录)', 'aksrt'))) . '
'; + $html .= '
' . esc_html(sprintf(__('禁封原因:%s', 'aksrt'), (string) ($ban['reason'] ?? ''))) . '
'; + $html .= '
' . esc_html(sprintf(__('开始时间:%s', 'aksrt'), (string) ($ban['created_at'] ?? ''))) . '
'; + $html .= '
' . esc_html(sprintf(__('结束时间:%s', 'aksrt'), $end)) . '
'; + return $html; + } + + if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) { + return '
' . esc_html(sprintf(__('限制状态:%s', 'aksrt'), __('停权中', 'aksrt'))) . '
'; + } + if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) { + return '
' . esc_html(sprintf(__('限制状态:%s', 'aksrt'), __('禁言中', 'aksrt'))) . '
'; + } + + return '
' . esc_html__('用户状态正常', 'aksrt') . '
'; + } + + private function ticket_target_link(string $target_type, int $target_id): string { + if ($target_type === 'post') { + $post = get_post($target_id); + if ($post instanceof \WP_Post) { + return '' . esc_html(get_the_title($post)) . ''; + } + } elseif ($target_type === 'comment') { + $comment = get_comment($target_id); + if ($comment instanceof \WP_Comment) { + return '#' . $target_id . ''; + } + } elseif ($target_type === 'user') { + $url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($target_id) : ''; + $user = get_userdata($target_id); + if ($url !== '' && $user) { + return '' . esc_html($user->display_name) . ''; + } + } + return ''; + } + + /** 行内处理表单(对齐参考主题 form-table 结构) */ + private function render_ticket_process_form(array $row, string $action_url): void { + $kind = $row['kind']; + + echo '
'; + echo ''; + + if ($kind === 'report') { + $r = $row['payload']; + $target_type = sanitize_key((string) ($r['target_type'] ?? '')); + $target_id = (int) ($r['target_id'] ?? 0); + $link = $this->ticket_target_link($target_type, $target_id); + + echo ''; + + echo ''; + + if ($target_type === 'user') { + echo ''; + + echo ''; + } else { + echo ''; + } + + echo ''; + + echo ''; + + wp_nonce_field('aksrt_users_update_report_' . (int) $row['id']); + } else { + $uid = (int) $row['id']; + $user = get_userdata($uid); + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + wp_nonce_field('aksrt_users_process_appeal_' . $uid); + } + + echo ''; + echo '
' . esc_html__('被举报目标', 'aksrt') . ''; + echo $link !== '' ? $link : esc_html($target_type . ' #' . $target_id); + echo ''; + echo '
' . esc_html__('举报信息', 'aksrt') . ''; + echo esc_html(sprintf(__('举报原因:%s', 'aksrt'), (string) ($r['reason'] ?? ''))) . '
'; + if (!empty($r['category'])) { + echo esc_html(sprintf(__('分类:%s', 'aksrt'), (string) $r['category'])) . '
'; + } + echo esc_html(sprintf(__('提交时间:%s', 'aksrt'), (string) $row['time'])); + echo '
' . esc_html__('被举报用户限制信息', 'aksrt') . ''; + echo $this->ticket_ban_info_html($target_id); + echo '

' . esc_html__('如举报事实清楚,可对用户执行处罚动作,处理结果会反馈给举报人。', 'aksrt') . '

'; + echo '
' . esc_html__('处罚动作', 'aksrt') . '
' . esc_html__('内容处理', 'aksrt') . '
' . esc_html__('处理结果', 'aksrt') . '
' . esc_html__('处理说明', 'aksrt') . ''; + echo ''; + echo '

' . esc_html__('此处内容会反馈给举报人', 'aksrt') . '

'; + echo '
' . esc_html__('申诉用户', 'aksrt') . ''; + if ($user instanceof \WP_User) { + $url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($uid) : get_author_posts_url($uid); + echo '' . esc_html($user->display_name) . ''; + } else { + echo esc_html('#' . $uid); + } + echo ''; + echo '
' . esc_html__('禁封信息', 'aksrt') . '' . $this->ticket_ban_info_html($uid) . '
' . esc_html__('申诉信息', 'aksrt') . ''; + $payload = $row['payload']; + echo esc_html(sprintf(__('申诉类型:%s', 'aksrt'), (string) ($payload['reason_key'] ?? '—'))) . '
'; + echo esc_html(sprintf(__('申诉说明:%s', 'aksrt'), (string) ($payload['reason'] ?? ''))) . '
'; + echo esc_html(sprintf(__('提交时间:%s', 'aksrt'), (string) ($payload['time'] ?? ''))); + echo '
' . esc_html__('处理方式', 'aksrt') . ''; + echo '

'; + echo '

'; + echo '
' . esc_html__('处理留言', 'aksrt') . ''; + echo ''; + echo '

' . esc_html__('如需给用户留言请填写此处,如果拒绝请填写拒绝原因', 'aksrt') . '

'; + echo '

'; + } +} diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php index 045e94f0..668bdb18 100644 --- a/src/Admin/UserControllerReportOperations.php +++ b/src/Admin/UserControllerReportOperations.php @@ -354,61 +354,10 @@ trait UserControllerReportOperations { } private function apply_report_action(string $target_type, int $target_id, string $action_taken, string $reason = ''): string { - if ($target_id <= 0 || $action_taken === 'none') { + if (!class_exists('\AKSRT\Messages\ReportCenter')) { return ''; } - - if ($target_type === 'user') { - if (!class_exists('\AKSRT\User\Restriction')) { - return ''; - } - $svc = \AKSRT\User\Restriction::get_instance(); - - if ($action_taken === 'clear') { - $removed = $svc->lift_user_restriction($target_id); - return $removed > 0 - ? sprintf(__('已解除 %d 条生效中的限制', 'aksrt'), $removed) - : __('该用户没有生效中的限制', 'aksrt'); - } - - if (in_array($action_taken, \AKSRT\User\Restriction::TYPES, true)) { - $labels = [ - \AKSRT\User\Restriction::TYPE_WARN => __('警告', 'aksrt'), - \AKSRT\User\Restriction::TYPE_MUTE => __('禁言', 'aksrt'), - \AKSRT\User\Restriction::TYPE_SUSPEND => __('停权', 'aksrt'), - \AKSRT\User\Restriction::TYPE_BAN => __('封禁', 'aksrt'), - ]; - $result = $svc->restrict( - $target_id, - $action_taken, - $reason !== '' ? $reason : __('举报处理处罚', 'aksrt'), - \AKSRT\User\Restriction::SCOPE_GLOBAL, - '', - get_current_user_id() - ); - if (!empty($result['success'])) { - return sprintf(__('已对用户执行限制:%s', 'aksrt'), $labels[$action_taken] ?? $action_taken); - } - return ''; - } - - return ''; - } - - if ($action_taken === 'content_removed' && $target_type === 'post' && get_post($target_id)) { - wp_update_post([ - 'ID' => $target_id, - 'post_status' => 'pending', - ]); - return __('已将内容改为待审状态', 'aksrt'); - } - - if ($action_taken === 'content_removed' && $target_type === 'comment' && get_comment($target_id)) { - wp_set_comment_status($target_id, 'hold', true); - return __('已将评论改为待审状态', 'aksrt'); - } - - return ''; + return \AKSRT\Messages\ReportCenter::get_instance()->apply_action($target_type, $target_id, $action_taken, $reason); } private function get_report_status_options(): array { diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php index c32db01c..9185e35c 100644 --- a/src/Messages/ReportCenter.php +++ b/src/Messages/ReportCenter.php @@ -159,6 +159,87 @@ class ReportCenter { return $result !== false; } + /** + * 获取单条举报原始行(ARRAY_A),不存在返回 null。 + */ + public function get_report_row(int $report_id): ?array { + $table = $this->table(); + if ($table === '' || $report_id <= 0) { + return null; + } + + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $report_id), ARRAY_A); + return $row ?: null; + } + + /** + * 对举报目标执行处罚动作(REST 与原生后台共用入口)。 + * + * @param string $target_type 目标类型 post/comment/user + * @param int $target_id 目标 ID + * @param string $action_taken none/warn/mute/suspend/ban/clear/content_removed + * @param string $reason 处罚原因(写入限制记录) + * @return string 执行结果描述,未执行返回空串 + */ + public function apply_action(string $target_type, int $target_id, string $action_taken, string $reason = ''): string { + if ($target_id <= 0 || $action_taken === 'none') { + return ''; + } + + if ($target_type === 'user') { + if (!class_exists('\AKSRT\User\Restriction')) { + return ''; + } + $svc = \AKSRT\User\Restriction::get_instance(); + + if ($action_taken === 'clear') { + $removed = $svc->lift_user_restriction($target_id); + return $removed > 0 + ? sprintf(__('已解除 %d 条生效中的限制', 'aksrt'), $removed) + : __('该用户没有生效中的限制', 'aksrt'); + } + + if (in_array($action_taken, \AKSRT\User\Restriction::TYPES, true)) { + $labels = [ + \AKSRT\User\Restriction::TYPE_WARN => __('警告', 'aksrt'), + \AKSRT\User\Restriction::TYPE_MUTE => __('禁言', 'aksrt'), + \AKSRT\User\Restriction::TYPE_SUSPEND => __('停权', 'aksrt'), + \AKSRT\User\Restriction::TYPE_BAN => __('封禁', 'aksrt'), + ]; + $result = $svc->restrict( + $target_id, + $action_taken, + $reason !== '' ? $reason : __('举报处理处罚', 'aksrt'), + \AKSRT\User\Restriction::SCOPE_GLOBAL, + '', + get_current_user_id() + ); + if (!empty($result['success'])) { + return sprintf(__('已对用户执行限制:%s', 'aksrt'), $labels[$action_taken] ?? $action_taken); + } + return ''; + } + + return ''; + } + + if ($action_taken === 'content_removed' && $target_type === 'post' && get_post($target_id)) { + wp_update_post([ + 'ID' => $target_id, + 'post_status' => 'pending', + ]); + return __('已将内容改为待审状态', 'aksrt'); + } + + if ($action_taken === 'content_removed' && $target_type === 'comment' && get_comment($target_id)) { + wp_set_comment_status($target_id, 'hold', true); + return __('已将评论改为待审状态', 'aksrt'); + } + + return ''; + } + /** * Get status counts for the admin dashboard. * diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php index 68b22318..c34b0424 100644 --- a/src/Settings/MailNotifications.php +++ b/src/Settings/MailNotifications.php @@ -176,7 +176,7 @@ final class MailNotifications { public static function report_submitted(int $report_id, array $report): void { $reporter = get_userdata((int) ($report['reporter_id'] ?? 0)); - self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_user_ban&tab=reports')]); + self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_user_ban')]); } public static function report_status_changed(int $report_id, int $reporter_id, string $status): void { diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index 2c1022df..bec279d7 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -43,11 +43,13 @@ test('report center has no legacy ajax handlers or option migration', () => { assert.match(reportOps, /has_pending_report\(\$reporter_id, \$target_type, \$target_id\)/) }) -test('report processing writes real restrictions, not a display-only meta', () => { - assert.match(reportOps, /->restrict\(\s*\$target_id,\s*\$action_taken,/) - assert.match(reportOps, /lift_user_restriction\(\$target_id\)/) - assert.match(reportOps, /in_array\(\$action_taken, \\AKSRT\\User\\Restriction::TYPES, true\)/) - assert.ok(!reportOps.includes('aksrt_user_ban_state'), 'dual-track ban state meta must not be written or read') +test('report processing delegates actions to the shared ReportCenter entry', () => { + assert.match(reportOps, /ReportCenter::get_instance\(\)->apply_action\(\$target_type, \$target_id, \$action_taken, \$reason\)/) + assert.match(reportCenter, /public function apply_action\(/) + assert.match(reportCenter, /->restrict\(\s*\$target_id,\s*\$action_taken,/) + assert.match(reportCenter, /lift_user_restriction\(\$target_id\)/) + assert.match(reportCenter, /in_array\(\$action_taken, \\AKSRT\\User\\Restriction::TYPES, true\)/) + assert.ok(!reportCenter.includes('aksrt_user_ban_state'), 'dual-track ban state meta must not be written or read') for (const deadAction of ["'value' => 'warned'", "'value' => 'muted'", "'value' => 'restricted'", "'value' => 'banned'"]) { assert.ok(!reportOps.includes(deadAction), `action value must map to Restriction types: ${deadAction}`) } @@ -55,6 +57,8 @@ test('report processing writes real restrictions, not a display-only meta', () = assert.ok(reportOps.includes(action), `missing action option: ${action}`) } assert.match(reportOps, /get_user_restriction_state/) + assert.match(reportCenter, /public function get_report_row\(/) + assert.match(usersMenus, /\$svc->apply_action\(/) }) test('restriction class supports lifting and kicks sessions on ban', () => { @@ -93,8 +97,46 @@ test('profile governance notice reads from the restriction table', () => { }) test('admin report notification links to the real page', () => { - assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban&tab=reports/) + assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban'/) assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link') + assert.ok(!mailNotifications.includes('tab=reports'), 'merged ticket page has no reports tab') +}) + +test('native ban page mirrors the reference theme layout', () => { + const tickets = read('src/Admin/Native/UsersMenusTickets.php') + + // 菜单与页面标题对齐参考主题 + assert.ok(usersMenus.includes(`__('举报&禁封申诉', 'aksrt'),\n __('举报&禁封申诉', 'aksrt')`), 'submenu title should be 举报&禁封申诉') + assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme') + + // 类型筛选按钮组 + 状态 subsubsub + 搜索 + for (const label of [`__('禁封申诉', 'aksrt')`, `__('用户举报', 'aksrt')`, `__('待处理', 'aksrt')`, `__('已批准', 'aksrt')`, `__('已拒绝', 'aksrt')`, `__('搜索记录', 'aksrt')`]) { + assert.ok(tickets.includes(label), `missing reference-theme label: ${label}`) + } + + // 表头列与参考主题一致:状态/类型/提交用户/详情/提交时间/更新时间(带排序) + for (const head of ['状态', '类型', '提交用户', '详情', '提交时间', '更新时间']) { + assert.ok(tickets.includes(`__('${head}', 'aksrt')`), `missing table head: ${head}`) + } + assert.match(tickets, /orderby-but/) + assert.match(tickets, /dashicons-arrow-up/) + + // 行内处理表单对齐参考主题 + for (const label of [`__('立即处理', 'aksrt')`, `__('申诉通过(解封该用户)', 'aksrt')`, `__('申诉拒绝(保持原禁封状态)', 'aksrt')`, `__('此处内容会反馈给举报人', 'aksrt')`, `__('确认提交', 'aksrt')`, `__('用户状态正常', 'aksrt')`, `__('暂无申请记录', 'aksrt')`]) { + assert.ok(tickets.includes(label), `missing process form label: ${label}`) + } + + // 分页(每页 20 条,与参考主题一致) + assert.match(tickets, /TICKETS_PER_PAGE = 20/) + assert.match(usersMenus, /private function pagination\(int \$pages, int \$page_num\): void/) + assert.match(usersMenus, /paginate_links\(/) + + // 旧的三 tab 结构已收敛 + assert.ok(!usersMenus.includes('tab=reports'), 'old reports tab is gone') + assert.ok(!usersMenus.includes('tab=appeals'), 'old appeals tab is gone') + assert.ok(!usersMenus.includes('render_reports_tab')) + assert.ok(!usersMenus.includes('render_appeals_tab')) + assert.match(usersMenus, /use UsersMenusTickets;/) }) test('comments expose a report entry for other users when reporting is enabled', () => {