From 59ddf677b693fdf59f824946385fcd281402de2f Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 30 Oct 2026 23:06:04 +0800 Subject: [PATCH] feat(admin): review reports and ban appeals in dialogs Co-Authored-By: Claude Fable 5 --- assets/css/admin/native-ui.css | 9 - assets/css/admin/user-ban.css | 125 ++++ assets/js/admin-user-ban.js | 139 +++++ src/Admin/Native/UsersMenus.php | 101 +--- src/Admin/Native/UsersMenusBan.php | 201 +++++++ src/Admin/Native/UsersMenusBanActions.php | 200 +++++++ src/Admin/Native/UsersMenusBanDialogs.php | 185 ++++++ src/Admin/Native/UsersMenusBanQuery.php | 387 +++++++++++++ src/Admin/Native/UsersMenusTickets.php | 538 ------------------ .../widget-system/ban-admin-contract.test.cjs | 177 ++++++ tests/widget-system/ban-admin-harness.php | 383 +++++++++++++ .../widget-system/ban-admin-runtime.test.cjs | 260 +++++++++ .../report-ban-contract.test.cjs | 121 ++-- 13 files changed, 2122 insertions(+), 704 deletions(-) create mode 100644 assets/css/admin/user-ban.css create mode 100644 assets/js/admin-user-ban.js create mode 100644 src/Admin/Native/UsersMenusBan.php create mode 100644 src/Admin/Native/UsersMenusBanActions.php create mode 100644 src/Admin/Native/UsersMenusBanDialogs.php create mode 100644 src/Admin/Native/UsersMenusBanQuery.php delete mode 100644 src/Admin/Native/UsersMenusTickets.php create mode 100644 tests/widget-system/ban-admin-contract.test.cjs create mode 100644 tests/widget-system/ban-admin-harness.php create mode 100644 tests/widget-system/ban-admin-runtime.test.cjs diff --git a/assets/css/admin/native-ui.css b/assets/css/admin/native-ui.css index 6e46b34f..a9788f1a 100644 --- a/assets/css/admin/native-ui.css +++ b/assets/css/admin/native-ui.css @@ -486,15 +486,6 @@ a.aksrt-native-stat:focus { outline: 2px solid color-mix(in srgb, var(--an-tone) .aksrt-native-text--info { color: var(--an-info); } .aksrt-native-text--warning { color: #c27c0e; } .aksrt-native-text--danger { color: var(--an-danger); } -.aksrt-native-clamp { max-height: 40px; overflow: hidden; } -/* 举报&禁封申诉页:宽表 + 排序箭头 */ -.aksrt-native-ban .aksrt-native-table-wrap > table { min-width: 1000px; } -.aksrt-native-ban .orderby-but { position: relative; display: inline-block; width: 0; height: 0; margin-left: 4px; } -.aksrt-native-ban .orderby-but > a { position: absolute; left: 0; bottom: .725em; padding-right: 1em; opacity: .4; transform: translateY(50%); transition: opacity .3s; } -.aksrt-native-ban .orderby-but > a + a { bottom: .075em; } -.aksrt-native-ban .orderby-but > a:hover { opacity: 1; } -.aksrt-native-ban .aksrt-native-toolbar + .subsubsub { float: none; margin: 10px 0 0; } -.aksrt-native-ban textarea { width: 95%; max-width: 500px; } .aksrt-native-amount { color: var(--an-text); font-weight: 600; font-variant-numeric: tabular-nums; } .aksrt-native-amount.is-positive { color: var(--an-primary); } .aksrt-native-amount.is-negative { color: var(--an-danger); } diff --git a/assets/css/admin/user-ban.css b/assets/css/admin/user-ban.css new file mode 100644 index 00000000..3f13515a --- /dev/null +++ b/assets/css/admin/user-ban.css @@ -0,0 +1,125 @@ +/* 举报与禁封申诉运营页。继承 native-ui.css 的后台颜色与表单令牌。 */ +.aksrt-ban-page [hidden], +.aksrt-ban-dialog [hidden] { display: none !important; } + +.aksrt-ban-toolbar { padding: 14px 20px; border-bottom: 1px solid var(--an-border-soft); } +.aksrt-ban-toolbar__top, +.aksrt-ban-toolbar__bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; } +.aksrt-ban-toolbar__bottom { margin-top: 14px; } +.aksrt-ban-types { display: inline-flex; flex-wrap: wrap; gap: 4px; padding: 3px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); } +.aksrt-ban-types a { padding: 6px 12px; color: var(--an-text-2); border-radius: 5px; text-decoration: none; } +.aksrt-ban-types a:hover { color: var(--an-primary); } +.aksrt-ban-types a[aria-current="page"] { color: var(--an-primary); background: var(--an-card); box-shadow: 0 1px 3px rgba(15, 23, 42, .08); font-weight: 600; } +.aksrt-ban-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; } +.aksrt-ban-toolbar .aksrt-native-filters { margin: 0; } +.aksrt-ban-toolbar .aksrt-native-filters input[type="search"] { min-width: 240px; } +.aksrt-ban-filter-hint { margin: 12px 0 0; color: var(--an-text-2); font-size: 12px; } + +.aksrt-ban-page .aksrt-ban-table { min-width: 1160px; table-layout: fixed; } +.aksrt-ban-table .column-type { width: 11%; } +.aksrt-ban-table .column-user { width: 16%; } +.aksrt-ban-table .column-summary { width: 27%; } +.aksrt-ban-table .column-status { width: 10%; } +.aksrt-ban-table .column-time, +.aksrt-ban-table .column-updated { width: 13%; } +.aksrt-ban-table .column-actions { width: 10%; } +.aksrt-ban-page .aksrt-ban-table td { overflow-wrap: anywhere; } +.aksrt-ban-sort { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; } +.aksrt-ban-sort span { font-size: 14px; } +.aksrt-ban-page .aksrt-ban-table th[aria-sort] a, +.aksrt-ban-page .aksrt-ban-table th a:hover { color: var(--an-primary); } +.aksrt-ban-sub { margin-top: 5px; color: var(--an-text-2); font-size: 12px; line-height: 1.6; } +.aksrt-ban-summary { display: -webkit-box; max-height: 3.1em; margin-top: 5px; color: var(--an-text-2); line-height: 1.55; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.aksrt-ban-user-filter { display: inline-block; margin: 5px 0 0 36px; font-size: 12px; } +.aksrt-ban-table .column-actions .button { white-space: nowrap; } +.aksrt-ban-page :is(a, button, input):focus-visible, +.aksrt-ban-dialog :is(a, button, input, textarea):focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; } + +html.aksrt-ban-modal-open { overflow: hidden; scrollbar-gutter: stable; } +.aksrt-ban-dialog { + width: min(780px, calc(100vw - 32px)); + max-width: none; + max-height: calc(100vh - 40px); + max-height: calc(100dvh - 40px); + margin: auto; + padding: 0; + overflow: hidden; + color: var(--an-text-2); + background: var(--an-card); + border: 1px solid var(--an-border); + border-radius: var(--an-radius); + box-shadow: var(--an-shadow-hover); +} +.aksrt-ban-dialog::backdrop { background: rgba(15, 23, 42, .45); } +.aksrt-ban-dialog__frame { display: flex; flex-direction: column; min-height: 0; max-height: inherit; margin: 0; } +.aksrt-ban-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 19px 24px 17px; border-bottom: 1px solid var(--an-border-soft); } +.aksrt-ban-dialog .aksrt-ban-dialog__head h2 { margin: 0; padding: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.45; } +.aksrt-ban-dialog__head p { max-width: 52ch; margin: 5px 0 0; font-size: 13px; line-height: 1.6; } +.aksrt-ban-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; color: var(--an-text-2); background: transparent; border: 0; border-radius: var(--an-radius-sm); cursor: pointer; } +.aksrt-ban-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); } +.aksrt-ban-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-ban-dialog__foot { display: flex; flex: none; justify-content: flex-end; gap: 8px; padding: 14px 24px; background: var(--an-card); border-top: 1px solid var(--an-border-soft); } +.aksrt-ban-dialog__foot .button { min-width: 120px; } +.aksrt-ban-dialog .aksrt-ban-dialog__foot .button.is-danger { color: var(--an-card); background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-ban-dialog .button:disabled, +.aksrt-ban-dialog__close:disabled { opacity: .65; cursor: not-allowed; } + +.aksrt-ban-applicant { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-bottom: 18px; } +.aksrt-ban-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 18px; margin: 0; padding: 14px 16px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); } +.aksrt-ban-meta > div, +.aksrt-ban-materials > div { min-width: 0; } +.aksrt-ban-meta dt, +.aksrt-ban-materials dt { color: var(--an-text-2); font-size: 12px; line-height: 1.6; } +.aksrt-ban-meta dd, +.aksrt-ban-materials dd { margin: 3px 0 0; color: var(--an-text); white-space: pre-wrap; overflow-wrap: anywhere; } +.aksrt-ban-section { margin-top: 20px; } +.aksrt-ban-dialog .aksrt-ban-section h3, +.aksrt-ban-dialog .aksrt-ban-review > h3 { margin: 0 0 9px; color: var(--an-text); font-size: 14px; font-weight: 600; line-height: 1.5; } +.aksrt-ban-copy { color: var(--an-text-2); line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; } +.aksrt-ban-target { color: var(--an-text); font-weight: 600; overflow-wrap: anywhere; } +.aksrt-ban-materials { display: grid; gap: 12px; margin: 0; padding-left: 14px; border-left: 2px solid var(--an-border); } +.aksrt-ban-restrictions { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-ban-restrictions li { margin: 0; padding: 12px 14px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); } +.aksrt-ban-restriction__title { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; font-size: 12px; } +.aksrt-ban-normal { margin: 0; color: var(--an-text-2); } +.aksrt-ban-profile-link { margin: 12px 0 18px; } +.aksrt-ban-history { margin: 0; padding-left: 14px; list-style: none; border-left: 2px solid var(--an-border); } +.aksrt-ban-history li { margin: 0 0 14px; } +.aksrt-ban-history li:last-child { margin-bottom: 0; } +.aksrt-ban-history__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-bottom: 5px; font-size: 12px; } +.aksrt-ban-history__meta strong { color: var(--an-text); } +.aksrt-ban-dialog__body > .aksrt-native-callout { margin-top: 18px; } + +.aksrt-ban-review { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--an-border); } +.aksrt-ban-decisions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; padding: 0; border: 0; } +.aksrt-ban-decisions legend { width: 100%; margin-bottom: 8px; padding: 0; color: var(--an-text); font-weight: 600; } +.aksrt-ban-decisions label { position: relative; flex: 1 1 180px; cursor: pointer; } +.aksrt-ban-decisions input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); } +.aksrt-ban-decisions label > span { display: flex; align-items: center; min-height: 42px; padding: 9px 12px; color: var(--an-text-2); background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); } +.aksrt-ban-decisions label:hover > span { border-color: var(--an-primary); } +.aksrt-ban-decisions input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; } +.aksrt-ban-decisions input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; } +.aksrt-ban-review textarea { resize: vertical; } +.aksrt-ban-review .aksrt-native-field__hint { margin-bottom: 0; color: var(--an-text-2); } + +@media (max-width: 782px) { + .aksrt-ban-toolbar { padding: 12px 14px; } + .aksrt-ban-toolbar__top, + .aksrt-ban-toolbar__bottom { align-items: flex-start; } + .aksrt-ban-toolbar .aksrt-native-filters { width: 100%; } + .aksrt-ban-toolbar .aksrt-native-filters input[type="search"] { min-width: 0; flex: 1 1 220px; } +} +@media (max-width: 600px) { + .aksrt-ban-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .aksrt-ban-dialog__head, + .aksrt-ban-dialog__body, + .aksrt-ban-dialog__foot { padding-left: 16px; padding-right: 16px; } + .aksrt-ban-meta { grid-template-columns: 1fr; gap: 10px; } + .aksrt-ban-decisions { flex-direction: column; } + .aksrt-ban-decisions label { flex-basis: auto; } + .aksrt-ban-dialog__foot .button { flex: 1; min-width: 0; } +} +@media (prefers-reduced-motion: reduce) { + .aksrt-ban-page *, + .aksrt-ban-dialog * { transition: none !important; } +} diff --git a/assets/js/admin-user-ban.js b/assets/js/admin-user-ban.js new file mode 100644 index 00000000..f724f5f1 --- /dev/null +++ b/assets/js/admin-user-ban.js @@ -0,0 +1,139 @@ +/** 举报与禁封申诉审核。仅在对应 native 后台页入队。 */ +(function () { + 'use strict'; + + var root = document.querySelector('.aksrt-ban-page'); + var dialog = document.getElementById('aksrt-ban-dialog'); + if (!root || !dialog || typeof dialog.showModal !== 'function') return; + + var opener = null; + var activeForm = null; + var submitting = false; + var pointerStartedOutside = false; + + function lockScroll() { + document.documentElement.classList.toggle('aksrt-ban-modal-open', !!dialog.open); + } + + function attribute(element, name) { + return element ? element.getAttribute(name) || '' : ''; + } + + function selectedDecision(form) { + var radios = form.querySelectorAll('input[type="radio"]'); + for (var index = 0; index < radios.length; index += 1) { + if (radios[index].checked) return radios[index].value; + } + return ''; + } + + function syncReview(form) { + if (submitting) return; + var decision = selectedDecision(form); + var rejected = decision === 'reject' || decision === 'dismissed'; + var note = form.querySelector('textarea[name="note"]'); + var label = form.querySelector('[data-aksrt-ban-note-label]'); + var submit = form.querySelector('[data-aksrt-ban-submit]'); + if (note) { + note.required = rejected; + note.setCustomValidity(rejected && !note.value.trim() ? attribute(note, 'data-required-error') : ''); + } + if (label) label.textContent = attribute(label, rejected ? 'data-required' : 'data-optional'); + if (submit) { + submit.disabled = !decision; + submit.textContent = attribute(submit, decision ? (rejected ? 'data-reject-label' : 'data-approve-label') : 'data-idle-label'); + submit.classList.toggle('is-danger', rejected); + } + } + + function setActionsDisabled(disabled) { + dialog.querySelectorAll('[data-aksrt-ban-submit], [data-aksrt-dialog-close]').forEach(function (button) { + button.disabled = disabled; + }); + } + + function setupReview(form) { + form.querySelectorAll('input[type="radio"]').forEach(function (radio) { radio.checked = false; }); + var note = form.querySelector('textarea[name="note"]'); + if (note) note.value = ''; + syncReview(form); + form.addEventListener('input', function () { syncReview(form); }); + form.addEventListener('change', function () { syncReview(form); }); + form.addEventListener('submit', function (event) { + if (submitting || form.dataset.submitting === '1') { + event.preventDefault(); + return; + } + syncReview(form); + if (!selectedDecision(form) || !form.reportValidity()) { + event.preventDefault(); + return; + } + submitting = true; + form.dataset.submitting = '1'; + form.setAttribute('aria-busy', 'true'); + setActionsDisabled(true); + var submit = form.querySelector('[data-aksrt-ban-submit]'); + if (submit) submit.textContent = attribute(submit, 'data-busy-label'); + }); + } + + root.querySelectorAll('[data-aksrt-ban-view]').forEach(function (button) { + button.addEventListener('click', function (event) { + event.preventDefault(); + if (submitting || dialog.open) return; + var template = document.getElementById('aksrt-ban-detail-' + attribute(button, 'data-aksrt-ban-view')); + if (!template || !template.content) return; + dialog.replaceChildren(template.content.cloneNode(true)); + opener = button; + pointerStartedOutside = false; + activeForm = dialog.querySelector('form[data-aksrt-ban-review]'); + if (activeForm) setupReview(activeForm); + dialog.showModal(); + lockScroll(); + }); + }); + + function isOutside(event) { + var rect = dialog.getBoundingClientRect(); + return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; + } + + dialog.addEventListener('pointerdown', function (event) { + pointerStartedOutside = event.target === dialog && isOutside(event); + }); + dialog.addEventListener('pointercancel', function () { pointerStartedOutside = false; }); + dialog.addEventListener('click', function (event) { + var close = event.target.closest('[data-aksrt-dialog-close]'); + if (close || (pointerStartedOutside && event.target === dialog && isOutside(event))) { + event.preventDefault(); + if (!submitting) dialog.close(); + } + pointerStartedOutside = false; + }); + dialog.addEventListener('cancel', function (event) { + event.preventDefault(); + if (!submitting) dialog.close(); + }); + dialog.addEventListener('close', function () { + dialog.replaceChildren(); + activeForm = null; + submitting = false; + pointerStartedOutside = false; + lockScroll(); + var previousOpener = opener; + opener = null; + if (previousOpener && previousOpener.isConnected) previousOpener.focus(); + }); + + window.addEventListener('pageshow', function () { + submitting = false; + if (activeForm) { + delete activeForm.dataset.submitting; + activeForm.removeAttribute('aria-busy'); + setActionsDisabled(false); + syncReview(activeForm); + } + lockScroll(); + }); +})(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 13a651ce..6e28e8b3 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -12,7 +12,7 @@ class UsersMenus { use UsersMenusVerify; use UsersMenusVip; use UsersMenusInviteCode; - use UsersMenusTickets; + use UsersMenusBan; use UsersMenusTicketsCenter; use UsersMenusMessages; @@ -47,6 +47,7 @@ class UsersMenus { add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_verify_assets']); + add_action('admin_enqueue_scripts', [$this, 'enqueue_ban_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_vip_assets']); } @@ -202,6 +203,9 @@ class UsersMenus { if ($code === 'verify_failed' && isset($_GET['aksrt_verify_error'])) { $message = sanitize_text_field((string) wp_unslash($_GET['aksrt_verify_error'])); } + if (in_array($code, ['report_failed', 'appeal_failed'], true) && isset($_GET['aksrt_ban_error'])) { + $message = sanitize_text_field((string) wp_unslash($_GET['aksrt_ban_error'])); + } if ($code === 'vip_failed' && isset($_GET['aksrt_vip_error'])) { $message = sanitize_text_field((string) wp_unslash($_GET['aksrt_vip_error'])); } @@ -221,101 +225,6 @@ class UsersMenus { exit; } - /* ------------------------------------------------------------------ - * Ban / reports - * ----------------------------------------------------------------*/ - - public function render_ban_page(): void { - $this->ensure_cap(); - NativeUi::page_open('aksrt-native-ban'); - NativeUi::header( - __('用户禁封申诉及用户举报管理', 'aksrt'), - __('处理用户举报与禁封申诉工单。禁封 / 解封操作在前台封禁弹窗完成。', 'aksrt'), - 'dashicons-shield' - ); - $this->flash(); - - // 与参考主题一致:后台仅承载「申诉&举报」工单处理;禁封/解封操作 - // 全部在前台完成(用户主页封面 / 评论区 / 文章作者栏三点菜单 → - // 封禁弹窗,REST /user-ban/modal + /user-ban/set)。 - // render_tickets_tab() 自带筛选控件 + 表格 + 分页,直接交给 .aksrt-native - // 作用域下的共享样式(.subsubsub / .widefat / .button / .tablenav)接管。 - $this->render_tickets_tab(); - - NativeUi::page_close(); - } - - - public function handle_update_report(): void { - 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'])) : ''; - 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' => 'tickets']); - } - - 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; - $decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : ''; - $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' => 'tickets', 'type' => 'appeal']); - } - - $status = $decision === 'approve' ? 'approved' : 'rejected'; - update_user_meta($user_id, \AKSRT\User\BanAppeal::META_STATUS, $status); - - $payload = \AKSRT\User\BanAppeal::get_payload($user_id); - $payload['admin_note'] = $note; - $payload['handled_at'] = current_time('Y-m-d H:i'); - $payload['handled_by'] = get_current_user_id(); - $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); - $history = is_array($history) ? $history : []; - $history[] = $payload; - update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30)); - - if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) { - \AKSRT\User\Restriction::get_instance()->lift_user_restriction($user_id); - } - - if (class_exists('\AKSRT\Messages\Message')) { - $msg = $decision === 'approve' - ? __('你的禁封申诉已通过,限制已解除。', 'aksrt') - : __('你的禁封申诉未通过,可修改后重新提交。', 'aksrt'); - if ($note !== '') { - $msg .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note); - } - \AKSRT\Messages\Message::get_instance()->send( - $user_id, - 'ban_appeal', - $msg, - [ - 'title' => $decision === 'approve' ? __('申诉通过', 'aksrt') : __('申诉驳回', 'aksrt'), - ] - ); - } - - do_action('aksrt_ban_appeal_processed', $user_id, $decision, $note); - - $this->redirect( - 'aksrt_user_ban', - $decision === 'approve' ? 'appeal_approved' : 'appeal_rejected', - ['tab' => 'tickets', 'type' => 'appeal'] - ); - } - private function pagination(int $pages, int $page_num): void { if ($pages <= 1) { return; diff --git a/src/Admin/Native/UsersMenusBan.php b/src/Admin/Native/UsersMenusBan.php new file mode 100644 index 00000000..112920be --- /dev/null +++ b/src/Admin/Native/UsersMenusBan.php @@ -0,0 +1,201 @@ +ensure_cap(); + $filters = $this->get_ban_filters($_GET); + $data = $this->query_ban_requests($filters, max(1, (int) ($_GET['paged'] ?? 1))); + $this->cache_ban_targets($data['items']); + $this->load_ban_restrictions($data['items']); + NativeUi::page_open('aksrt-ban-page'); + NativeUi::header( + __('举报与禁封申诉', 'aksrt'), + __('核对举报内容与申诉资料,在审核弹窗中记录处理结果。用户禁封操作仍在前台用户主页完成。', 'aksrt'), + 'dashicons-shield' + ); + $this->flash(); + NativeUi::card_open('', '', true, 'aksrt-ban-list-card'); + $this->render_ban_filters($filters, $data['counts']); + echo '
'; + $this->render_ban_table($data['items'], $filters, $data['page']); + NativeUi::table_wrap_close(); + AdminListPagination::render('aksrt_user_ban', $data['total'], $data['page'], self::BAN_PER_PAGE, $filters, 'users.php'); + NativeUi::card_close(); + echo ''; + echo ''; + NativeUi::page_close(); + } + + private function ban_page_url(array $args = []): string { + return add_query_arg(array_merge(['page' => 'aksrt_user_ban'], $args), admin_url('users.php')); + } + + private function render_ban_filters(array $filters, array $counts): void { + echo '

' . esc_html__('处理记录', 'aksrt') . '

'; + echo '
    '; + foreach (['pending' => __('待处理', 'aksrt'), 'resolved' => __('已处理 / 已通过', 'aksrt'), 'dismissed' => __('已驳回', 'aksrt'), '' => __('全部状态', 'aksrt')] as $key => $label) { + if ($filters['type'] === 'report' && $key === 'resolved') $label = __('已处理', 'aksrt'); + if ($filters['type'] === 'appeal' && $key === 'resolved') $label = __('已通过', 'aksrt'); + echo '
  • ' . esc_html($label) + . ' ' . esc_html(number_format_i18n($counts[$key === '' ? 'all' : $key])) . '
  • '; + } + echo '
'; + echo ''; + foreach (['type', 'status', 'send_user', 'orderby', 'order'] as $key) { + echo ''; + } + echo ''; + echo ''; + echo ''; + if ($filters['s'] !== '' || $filters['send_user'] > 0) { + echo '' . esc_html__('清除筛选', 'aksrt') . ''; + } + echo '
'; + if ($filters['send_user'] > 0) { + $user = get_userdata($filters['send_user']); + echo '

' . esc_html(sprintf(__('当前仅显示 %s 提交的记录。', 'aksrt'), $user ? $user->display_name : '#' . $filters['send_user'])) . '

'; + } + echo '
'; + } + + private function render_ban_table(array $items, array $filters, int $page): void { + echo ''; + foreach (['type' => __('类型 / 编号', 'aksrt'), 'user' => __('提交用户', 'aksrt'), 'summary' => __('事项摘要', 'aksrt'), 'status' => __('状态', 'aksrt'), 'time' => __('提交时间', 'aksrt'), 'updated' => __('更新时间', 'aksrt'), 'actions' => __('操作', 'aksrt')] as $key => $label) { + $sortable = in_array($key, ['type', 'status', 'time', 'updated'], true); + $active = $sortable && $filters['orderby'] === $key; + echo ''; + } + echo ''; + if ($items === []) { + $filtered = $filters['s'] !== '' || $filters['send_user'] > 0; + echo NativeUi::empty_row(7, + !$filtered && $filters['status'] === 'pending' ? __('暂无待处理记录', 'aksrt') : __('没有符合条件的记录', 'aksrt'), + $filtered ? __('请调整关键词、清除用户筛选或切换类型和状态。', 'aksrt') : __('可切换状态查看处理记录,新举报和申诉将在这里显示。', 'aksrt') + ); + } + foreach ($items as $row) $this->render_ban_row($row, $filters, $page); + echo '
' . esc_html__('举报与禁封申诉处理记录', 'aksrt') . '
'; + if ($sortable) { + $order = $active && $filters['order'] === 'DESC' ? 'ASC' : 'DESC'; + $title = sprintf($order === 'ASC' ? __('按%s升序排列', 'aksrt') : __('按%s降序排列', 'aksrt'), $label); + echo '' . esc_html($label) . ''; + } else { + echo esc_html($label); + } + echo '
'; + } + + private function render_ban_row(array $row, array $filters, int $page): void { + $report = $row['kind'] === 'report'; + $payload = $row['payload']; + $key = $row['kind'] . '-' . $row['id']; + $user_id = (int) $row['user_id']; + echo '' . NativeUi::badge($report ? __('用户举报', 'aksrt') : __('禁封申诉', 'aksrt'), $report ? 'info' : 'neutral', false); + echo '
#' . (int) $row['id'] . '
' . NativeUi::user_cell($user_id, __('用户已删除', 'aksrt')); + if ($user_id > 0) { + echo '' . esc_html__('筛选此用户', 'aksrt') . ''; + } + echo ''; + if ($report) { + echo '' . $this->ban_target_link((string) ($payload['target_type'] ?? ''), (int) ($payload['target_id'] ?? 0)) . ''; + } else { + echo '' . esc_html__('申请解除禁封', 'aksrt') . ''; + } + echo '
' . esc_html((string) ($payload['reason'] ?? '')) . '
'; + $this->render_ban_detail_template($row, $filters, $page); + echo '' . $this->ban_status_badge($row) . ''; + echo '' . esc_html($this->ban_time((string) $row['time'])) . ''; + echo '' . esc_html($this->ban_time((string) $row['updated'])) . ''; + echo ''; + } + + private function ban_status_badge(array $row): string { + if ($row['status'] === 'pending') return NativeUi::badge($row['raw_status'] === 'reviewing' ? __('审核中', 'aksrt') : __('待处理', 'aksrt'), 'warning'); + if ($row['status'] === 'resolved') return NativeUi::badge($row['kind'] === 'report' ? __('已处理', 'aksrt') : __('已通过', 'aksrt'), 'success'); + return NativeUi::badge(__('已驳回', 'aksrt'), 'danger'); + } + + private function ban_time(string $time): string { + return $time !== '' && $time !== '0000-00-00 00:00:00' ? $time : '—'; + } + + private function ban_user_home(int $user_id): string { + return function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user_id) : get_author_posts_url($user_id); + } + + private function ban_target_link(string $type, int $id): string { + $labels = ['post' => __('文章', 'aksrt'), 'comment' => __('评论', 'aksrt'), 'user' => __('用户', 'aksrt')]; + $label = ($labels[$type] ?? __('目标', 'aksrt')) . ' #' . $id; + $url = ''; + if ($type === 'post' && get_post($id)) { + $label = get_the_title($id) ?: $label; + $url = (string) get_permalink($id); + } elseif ($type === 'comment' && get_comment($id)) { + $url = (string) get_comment_link($id); + } elseif ($type === 'user' && ($user = get_userdata($id))) { + $label = $user->display_name . ' #' . $id; + $url = $this->ban_user_home($id); + } + return $url !== '' ? '' . esc_html($label) . '' : esc_html($label . ' ' . __('(目标已不可用)', 'aksrt')); + } + + private function cache_ban_targets(array $items): void { + $ids = ['post' => [], 'comment' => [], 'user' => []]; + foreach ($items as $row) { + if ($row['kind'] !== 'report') continue; + $type = (string) ($row['payload']['target_type'] ?? ''); + $id = (int) ($row['payload']['target_id'] ?? 0); + if (isset($ids[$type]) && $id > 0) $ids[$type][] = $id; + } + foreach ($ids as &$values) $values = array_values(array_unique($values)); + unset($values); + if ($ids['post'] !== []) _prime_post_caches($ids['post'], false, false); + if ($ids['comment'] !== []) _prime_comment_caches($ids['comment'], false); + if ($ids['user'] !== []) cache_users($ids['user']); + } + + private function load_ban_restrictions(array $items): void { + $this->ban_restrictions = []; + if (!class_exists(Restriction::class)) return; + $ids = []; + foreach ($items as $row) { + $id = $row['kind'] === 'appeal' ? (int) $row['user_id'] : (($row['payload']['target_type'] ?? '') === 'user' ? (int) $row['payload']['target_id'] : 0); + if ($id > 0) $ids[] = $id; + } + $ids = array_values(array_unique($ids)); + if ($ids === []) return; + global $wpdb; + $placeholders = implode(',', array_fill(0, count($ids), '%d')); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT user_id, type, scope, reason, created_at, expires_at FROM {$wpdb->prefix}aksrt_user_restriction WHERE user_id IN ({$placeholders}) AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC", + array_merge($ids, [current_time('mysql', true)]) + ), ARRAY_A); + foreach ((array) $rows as $row) $this->ban_restrictions[(int) $row['user_id']][] = $row; + } +} diff --git a/src/Admin/Native/UsersMenusBanActions.php b/src/Admin/Native/UsersMenusBanActions.php new file mode 100644 index 00000000..1e4fc298 --- /dev/null +++ b/src/Admin/Native/UsersMenusBanActions.php @@ -0,0 +1,200 @@ +ban_post_value('report_id')); + $new_status = sanitize_key($this->ban_post_value('new_status')); + $note = trim(sanitize_textarea_field($this->ban_post_value('note'))); + check_admin_referer('aksrt_users_update_report_' . $report_id); + + if ($report_id <= 0 || !in_array($new_status, ['resolved', 'dismissed'], true)) { + $this->redirect_ban_action('report_failed', __('举报处理请求无效。', 'aksrt')); + } + if ($new_status === 'dismissed' && $note === '') { + $this->redirect_ban_action('report_failed', __('驳回举报时必须填写处理说明。', 'aksrt')); + } + + $row = $this->find_ban_request('report', $report_id); + if (!$this->ban_request_is_current($row, 'pending')) { + $this->redirect_ban_action('report_failed', __('举报记录已变化或已处理,请刷新后重试。', 'aksrt')); + } + if (!class_exists('\\AKSRT\\Messages\\ReportCenter')) { + $this->redirect_ban_action('report_failed', __('举报处理服务不可用。', 'aksrt')); + } + + $ok = \AKSRT\Messages\ReportCenter::get_instance()->update_status($report_id, $new_status, $note); + $this->redirect_ban_action( + $ok ? 'report_updated' : 'report_failed', + $ok ? '' : __('举报状态更新失败,请稍后重试。', 'aksrt') + ); + } + + public function handle_process_appeal(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + + $user_id = absint($this->ban_post_value('user_id')); + $decision = sanitize_key($this->ban_post_value('decision')); + $note = trim(sanitize_textarea_field($this->ban_post_value('note'))); + check_admin_referer('aksrt_users_process_appeal_' . $user_id); + + if ($user_id <= 0 || !in_array($decision, ['approve', 'reject'], true)) { + $this->redirect_ban_action('appeal_failed', __('申诉处理请求无效。', 'aksrt')); + } + if ($decision === 'reject' && $note === '') { + $this->redirect_ban_action('appeal_failed', __('驳回申诉时必须填写拒绝原因。', 'aksrt')); + } + + $row = $this->find_ban_request('appeal', $user_id); + if (!$this->ban_request_is_current($row, 'pending')) { + $this->redirect_ban_action('appeal_failed', __('申诉记录已变化或已处理,请刷新后重试。', 'aksrt')); + } + + $user = get_userdata($user_id); + if (!($user instanceof \WP_User)) { + $this->redirect_ban_action('appeal_failed', __('申诉用户不存在,无法处理。', 'aksrt')); + } + + if ($decision === 'approve') { + if (!class_exists('\\AKSRT\\User\\Restriction')) { + $this->redirect_ban_action('appeal_failed', __('用户限制服务不可用,无法通过申诉。', 'aksrt')); + } + global $wpdb; + $restriction_service = \AKSRT\User\Restriction::get_instance(); + $restriction_service->lift_user_restriction($user_id); + $lift_error = (string) $wpdb->last_error; + if ($lift_error !== '') { + $this->redirect_ban_action('appeal_failed', __('解除限制时发生数据库错误,申诉未通过。', 'aksrt')); + } + + $active_restrictions = $restriction_service->get_user_restrictions($user_id, true); + $check_error = (string) $wpdb->last_error; + if ($check_error !== '') { + $this->redirect_ban_action('appeal_failed', __('无法核实限制是否解除,限制可能已解除,请检查用户当前状态。', 'aksrt')); + } + foreach ($active_restrictions as $restriction) { + if (in_array($restriction['type'] ?? '', [ + \AKSRT\User\Restriction::TYPE_BAN, + \AKSRT\User\Restriction::TYPE_SUSPEND, + \AKSRT\User\Restriction::TYPE_MUTE, + ], true)) { + $this->redirect_ban_action('appeal_failed', __('仍有生效限制,申诉未通过。', 'aksrt')); + } + } + } + + $raw_status = $decision === 'approve' ? 'approved' : 'rejected'; + $payload = $row['payload']; + $payload['admin_note'] = $note; + $payload['handled_at'] = current_time('Y-m-d H:i'); + $payload['handled_by'] = get_current_user_id(); + $payload['decision'] = $raw_status; + + $history = get_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, true); + $history = is_array($history) ? $history : []; + $history[] = $payload; + $history = array_slice($history, -30); + + $old_payload = $row['payload']; + $old_history = get_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, true); + $payload_updated = update_user_meta($user_id, \AKSRT\User\BanAppeal::META_PAYLOAD, $payload, $old_payload); + if ($payload_updated === false) { + $this->redirect_ban_action('appeal_failed', $this->appeal_metadata_error(__('申诉处理记录保存失败。', 'aksrt'), $decision)); + } + + $status_updated = update_user_meta($user_id, \AKSRT\User\BanAppeal::META_STATUS, $raw_status, 'pending'); + if ($status_updated === false) { + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_PAYLOAD, $old_payload); + $this->redirect_ban_action('appeal_failed', $this->appeal_metadata_error(__('申诉状态保存失败。', 'aksrt'), $decision)); + } + + $history_updated = update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, $history); + if ($history_updated === false) { + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_STATUS, 'pending'); + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_PAYLOAD, $old_payload); + if ($old_history === '' || $old_history === null) { + delete_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY); + } else { + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, $old_history); + } + $this->redirect_ban_action('appeal_failed', $this->appeal_metadata_error(__('申诉历史保存失败。', 'aksrt'), $decision)); + } + + if (class_exists('\\AKSRT\\Messages\\Message')) { + $message = $decision === 'approve' + ? __('你的禁封申诉已通过,限制已解除。', 'aksrt') + : __('你的禁封申诉未通过,可修改后重新提交。', 'aksrt'); + if ($note !== '') { + $message .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note); + } + \AKSRT\Messages\Message::get_instance()->send( + $user_id, + 'ban_appeal', + $message, + ['title' => $decision === 'approve' ? __('申诉通过', 'aksrt') : __('申诉驳回', 'aksrt')] + ); + } + + do_action('aksrt_ban_appeal_processed', $user_id, $decision, $note); + $this->redirect_ban_action($decision === 'approve' ? 'appeal_approved' : 'appeal_rejected'); + } + + private function appeal_metadata_error(string $message, string $decision): string { + if ($decision === 'approve') { + return $message . ' ' . __('限制可能已解除,请核对用户当前状态。', 'aksrt'); + } + return $message; + } + + private function ban_request_is_current(?array $row, string $status): bool { + if ($row === null || ($row['status'] ?? '') !== $status) { + return false; + } + $submitted = sanitize_text_field($this->ban_post_value('record_version')); + return $submitted !== '' && hash_equals($this->ban_record_version($row), $submitted); + } + + private function ban_post_value(string $key, string $default = ''): string { + if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) { + return $default; + } + return (string) wp_unslash($_POST[$key]); + } + + private function redirect_ban_action(string $message, string $error = ''): void { + $filters = $this->get_ban_filters([ + 'type' => $_POST['filter_type'] ?? '', + 'status' => $_POST['filter_status'] ?? 'pending', + 's' => $_POST['s'] ?? '', + 'send_user' => $_POST['send_user'] ?? '', + 'orderby' => $_POST['orderby'] ?? 'time', + 'order' => $_POST['order'] ?? 'DESC', + ]); + $extra = [ + 'type' => $filters['type'], + 'status' => $filters['status'], + 's' => $filters['s'], + 'send_user' => $filters['send_user'], + 'orderby' => $filters['orderby'], + 'order' => $filters['order'], + 'paged' => max(1, absint($this->ban_post_value('paged', '1'))), + ]; + if ($error !== '') { + $extra['aksrt_ban_error'] = $error; + } + $this->redirect('aksrt_user_ban', $message, $extra); + } +} diff --git a/src/Admin/Native/UsersMenusBanDialogs.php b/src/Admin/Native/UsersMenusBanDialogs.php new file mode 100644 index 00000000..d88e644c --- /dev/null +++ b/src/Admin/Native/UsersMenusBanDialogs.php @@ -0,0 +1,185 @@ +'; + if ($pending) { + echo '
'; + $this->render_ban_context($row, $filters, $page); + } else { + echo '
'; + } + $title = $report + ? ($pending ? __('审核用户举报', 'aksrt') : __('用户举报详情', 'aksrt')) + : ($pending ? __('审核禁封申诉', 'aksrt') : __('禁封申诉详情', 'aksrt')); + echo '

' . esc_html($title) . '

'; + echo '

' . esc_html($pending ? __('先核对原始资料,再选择处理结果;提交后将通知用户。', 'aksrt') : __('该记录已处理,以下资料和结果仅供查看。', 'aksrt')) . '

'; + echo '
'; + echo '
' . NativeUi::user_cell((int) $row['user_id'], __('用户已删除', 'aksrt')) . $this->ban_status_badge($row) . '
'; + echo '
'; + $this->render_ban_meta_item(__('记录编号', 'aksrt'), ($report ? __('举报', 'aksrt') : __('申诉', 'aksrt')) . ' #' . $row['id']); + $this->render_ban_meta_item(__('提交时间', 'aksrt'), $this->ban_time((string) $row['time'])); + $this->render_ban_meta_item(__('更新时间', 'aksrt'), $this->ban_time((string) $row['updated'])); + echo '
'; + if ($report) { + $this->render_ban_report_details($row); + } else { + $this->render_ban_appeal_details($row); + } + $this->render_ban_review_history($row); + if ($pending) $this->render_ban_review_fields($report); + echo '
'; + if ($pending) { + echo ''; + } + echo '
' . ($pending ? '' : '
') . ''; + } + + private function render_ban_context(array $row, array $filters, int $page): void { + $report = $row['kind'] === 'report'; + $action = $report ? 'aksrt_users_update_report' : 'aksrt_users_process_appeal'; + $fields = [ + 'action' => $action, + $report ? 'report_id' : 'user_id' => (int) $row['id'], + 'record_version' => $this->ban_record_version($row), + 'filter_type' => $filters['type'], + 'filter_status' => $filters['status'], + 's' => $filters['s'], + 'send_user' => $filters['send_user'], + 'orderby' => $filters['orderby'], + 'order' => $filters['order'], + 'paged' => $page, + ]; + foreach ($fields as $name => $value) { + echo ''; + } + wp_nonce_field($action . '_' . (int) $row['id']); + } + + private function render_ban_meta_item(string $label, string $value): void { + echo '
' . esc_html($label) . '
' . esc_html($value !== '' ? $value : '—') . '
'; + } + + private function render_ban_text_section(string $title, string $text): void { + echo '

' . esc_html($title) . '

'; + // Evidence URLs remain links: opening a case must not load reporter-supplied remote images. + $parts = preg_split('~(https?://[^\s<>"\x27]+)~u', $text, -1, PREG_SPLIT_DELIM_CAPTURE); + foreach ($parts ?: [$text] as $index => $part) { + echo $index % 2 === 1 + ? '' . esc_html($part) . '' + : esc_html($part !== '' ? $part : ''); + } + if ($text === '') echo esc_html__('未填写', 'aksrt'); + echo '
'; + } + + private function render_ban_report_details(array $row): void { + $payload = $row['payload']; + $type = (string) ($payload['target_type'] ?? ''); + $id = (int) ($payload['target_id'] ?? 0); + echo '

' . esc_html__('举报对象', 'aksrt') . '

' . $this->ban_target_link($type, $id) . '
'; + if (!empty($payload['category'])) $this->render_ban_text_section(__('举报分类', 'aksrt'), (string) $payload['category']); + $this->render_ban_text_section(__('举报内容与举证', 'aksrt'), (string) ($payload['reason'] ?? '')); + if ($type === 'user') { + $this->render_ban_restriction_section($id); + if (get_userdata($id)) { + echo ''; + } + } + if ($row['status'] === 'pending') { + echo '

' . esc_html__('本次审核仅记录举报处理结果,不会自动禁封用户或删除内容。请先核实举证并完成必要处置。', 'aksrt') . '

'; + } + } + + private function render_ban_appeal_details(array $row): void { + $payload = $row['payload']; + $this->render_ban_restriction_section((int) $row['user_id']); + if (!empty($payload['reason_key'])) $this->render_ban_text_section(__('申诉类型', 'aksrt'), (string) $payload['reason_key']); + $data = isset($payload['data']) && is_array($payload['data']) ? $payload['data'] : []; + if ($data !== []) { + echo '

' . esc_html__('申诉资料', 'aksrt') . '

'; + foreach ($data as $label => $value) { + if (is_scalar($value)) $this->render_ban_meta_item((string) $label, (string) $value); + } + echo '
'; + } + $reason = (string) ($payload['reason'] ?? ''); + if ($data === [] || !in_array($reason, $data, true)) $this->render_ban_text_section(__('申诉说明', 'aksrt'), $reason); + if (get_userdata((int) $row['user_id'])) { + echo ''; + } + } + + private function render_ban_restriction_section(int $user_id): void { + echo '

' . esc_html__('当前限制', 'aksrt') . '

'; + if (!class_exists(Restriction::class)) { + echo '

' . esc_html__('限制服务不可用,暂时无法核对当前状态。', 'aksrt') . '

'; + return; + } + $rows = $this->ban_restrictions[$user_id] ?? []; + if ($rows === []) { + echo '

' . esc_html__('当前无生效限制。此处仅展示当前状态,不会自动更改处理结果。', 'aksrt') . '

'; + return; + } + $labels = [Restriction::TYPE_WARN => __('警告', 'aksrt'), Restriction::TYPE_MUTE => __('禁言', 'aksrt'), Restriction::TYPE_SUSPEND => __('停权', 'aksrt'), Restriction::TYPE_BAN => __('封禁', 'aksrt')]; + echo '
    '; + foreach ($rows as $restriction) { + echo '
  • ' . NativeUi::badge($labels[$restriction['type']] ?? (string) $restriction['type'], 'warning', false) + . '' . esc_html(($restriction['scope'] ?? '') === Restriction::SCOPE_COMMENT ? __('评论范围', 'aksrt') : __('全站范围', 'aksrt')) . '
    '; + echo '
    ' . esc_html((string) ($restriction['reason'] ?? '')) . '
    '; + $start = !empty($restriction['created_at']) ? get_date_from_gmt((string) $restriction['created_at'], 'Y-m-d H:i') : '—'; + $end = !empty($restriction['expires_at']) ? get_date_from_gmt((string) $restriction['expires_at'], 'Y-m-d H:i') : __('永久', 'aksrt'); + echo '
    ' . esc_html(sprintf(__('开始:%1$s;结束:%2$s', 'aksrt'), $start, $end)) . '
  • '; + } + echo '
'; + } + + private function render_ban_review_history(array $row): void { + $payload = $row['payload']; + $notes = $row['kind'] === 'report' ? (array) ($payload['notes'] ?? []) : []; + if ($row['kind'] === 'appeal' && !empty($payload['handled_at'])) { + $notes[] = ['note' => $payload['admin_note'] ?? '', 'operator' => $payload['handled_by'] ?? 0, 'timestamp' => $payload['handled_at'], 'status' => $payload['decision'] ?? $row['raw_status']]; + } + if ($notes === []) return; + $labels = ['pending' => __('待处理', 'aksrt'), 'reviewing' => __('审核中', 'aksrt'), 'resolved' => __('已处理', 'aksrt'), 'dismissed' => __('已驳回', 'aksrt'), 'approved' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt')]; + echo '

' . esc_html__('处理记录', 'aksrt') . '

    '; + foreach ($notes as $note) { + if (!is_array($note)) continue; + $operator = get_userdata((int) ($note['operator'] ?? 0)); + echo '
  1. ' . esc_html($labels[$note['status'] ?? ''] ?? (string) ($note['status'] ?? '')) . '' + . esc_html($operator ? $operator->display_name : __('管理员', 'aksrt')) . '
    '; + echo '
    ' . esc_html((string) ($note['note'] ?? '') !== '' ? (string) $note['note'] : __('未填写处理留言', 'aksrt')) . '
  2. '; + } + echo '
'; + } + + private function render_ban_review_fields(bool $report): void { + echo '

' . esc_html__('审核处理', 'aksrt') . '

' . esc_html__('处理结果(必选)', 'aksrt') . ''; + $choices = $report ? ['resolved' => __('标记已处理', 'aksrt'), 'dismissed' => __('驳回举报', 'aksrt')] : ['approve' => __('通过申诉', 'aksrt'), 'reject' => __('驳回申诉', 'aksrt')]; + foreach ($choices as $value => $label) { + echo ''; + } + echo '
'; + if (!$report) { + echo '

' . esc_html__('通过申诉将解除该用户当前生效的封禁、停权和禁言,保留警告记录;驳回不会改变限制。', 'aksrt') . '

'; + } + echo ''; + echo '

' . esc_html($report ? __('处理结果及说明会反馈给举报人。驳回时请填写具体原因。', 'aksrt') : __('处理结果及说明会反馈给申诉用户。驳回时请说明原因,便于用户补充资料。', 'aksrt')) . '

'; + } +} diff --git a/src/Admin/Native/UsersMenusBanQuery.php b/src/Admin/Native/UsersMenusBanQuery.php new file mode 100644 index 00000000..77050037 --- /dev/null +++ b/src/Admin/Native/UsersMenusBanQuery.php @@ -0,0 +1,387 @@ +ban_filter_string($input, 'type')); + if (!in_array($type, ['report', 'appeal'], true)) { + $type = ''; + } + + $status = sanitize_key($this->ban_filter_string($input, 'status', 'pending')); + if ($status !== '' && !in_array($status, ['pending', 'resolved', 'dismissed'], true)) { + $status = 'pending'; + } + + $orderby = sanitize_key($this->ban_filter_string($input, 'orderby', 'time')); + if (!in_array($orderby, ['time', 'updated', 'status', 'type'], true)) { + $orderby = 'time'; + } + + $order = strtoupper($this->ban_filter_string($input, 'order', 'DESC')); + if (!in_array($order, ['ASC', 'DESC'], true)) { + $order = 'DESC'; + } + + return [ + 'type' => $type, + 'status' => $status, + 's' => sanitize_text_field($this->ban_filter_string($input, 's')), + 'send_user' => absint($this->ban_filter_string($input, 'send_user')), + 'orderby' => $orderby, + 'order' => $order, + ]; + } + + /** + * @return array{items:array>,total:int,page:int,counts:array{all:int,pending:int,resolved:int,dismissed:int}} + */ + private function query_ban_requests(array $filters, int $page): array { + $rows = []; + + if ($filters['type'] !== 'appeal') { + $rows = array_merge($rows, $this->query_ban_report_rows($filters)); + } + if ($filters['type'] !== 'report') { + $rows = array_merge($rows, $this->query_ban_appeal_rows($filters)); + } + + $counts = ['all' => 0, 'pending' => 0, 'resolved' => 0, 'dismissed' => 0]; + $filtered = []; + foreach ($rows as $row) { + $counts['all']++; + $counts[$row['status']]++; + if ($filters['status'] === '' || $row['status'] === $filters['status']) { + $filtered[] = $row; + } + } + + $this->sort_ban_requests($filtered, $filters['orderby'], $filters['order']); + $total = count($filtered); + $page = min(max(1, $page), max(1, (int) ceil($total / self::BAN_PER_PAGE))); + + $items = array_slice($filtered, ($page - 1) * self::BAN_PER_PAGE, self::BAN_PER_PAGE); + $this->load_ban_report_notes($items); + $this->cache_ban_request_users($items, $filters['send_user']); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'counts' => $counts, + ]; + } + + private function ban_record_version(array $row): string { + return hash('sha256', serialize([ + 'kind' => (string) ($row['kind'] ?? ''), + 'id' => (int) ($row['id'] ?? 0), + 'user_id' => (int) ($row['user_id'] ?? 0), + 'status' => (string) ($row['status'] ?? ''), + 'raw_status' => (string) ($row['raw_status'] ?? ''), + 'time' => (string) ($row['time'] ?? ''), + 'updated' => (string) ($row['updated'] ?? ''), + 'payload' => (array) ($row['payload'] ?? []), + ])); + } + + private function ban_filter_string(array $input, string $key, string $default = ''): string { + if (!array_key_exists($key, $input) || !is_scalar($input[$key])) { + return $default; + } + return (string) wp_unslash($input[$key]); + } + + /** @return array> */ + private function query_ban_report_rows(array $filters): array { + global $wpdb; + + if (!class_exists('\\AKSRT\\Core\\DBMigrator')) { + return []; + } + $table = \AKSRT\Core\DBMigrator::table('report'); + if ($table === '') { + return []; + } + + $where = ['r.status IN (%s, %s, %s, %s)']; + $params = ['pending', 'reviewing', 'resolved', 'dismissed']; + if ($filters['send_user'] > 0) { + $where[] = 'r.reporter_id = %d'; + $params[] = $filters['send_user']; + } + if ($filters['s'] !== '') { + $where[] = '(CONCAT_WS(\' \', r.reason, r.category, r.target_type, CONCAT(\'#\', r.target_id)) LIKE %s' + . ' OR CAST(r.id AS CHAR) LIKE %s OR CAST(r.reporter_id AS CHAR) LIKE %s' + . ' OR report_user.display_name LIKE %s OR report_user.user_login LIKE %s OR report_user.user_email LIKE %s)'; + $like = '%' . $wpdb->esc_like($filters['s']) . '%'; + array_push($params, $like, $like, $like, $like, $like, $like); + } + + $sql = 'SELECT r.id, r.reporter_id, r.target_type, r.target_id, r.reason, r.category, r.status, r.created_at, r.updated_at' + . " FROM {$table} AS r LEFT JOIN {$wpdb->users} AS report_user ON report_user.ID = r.reporter_id" + . ' WHERE ' . implode(' AND ', $where); + $sql = $wpdb->prepare($sql, $params); + + $reports = $wpdb->get_results($sql, ARRAY_A); + $rows = []; + foreach ((array) $reports as $report) { + if (is_array($report)) { + $row = $this->normalize_ban_report_row($report); + if ($row !== null) { + $rows[] = $row; + } + } + } + return $rows; + } + + /** @return array> */ + private function query_ban_appeal_rows(array $filters): array { + if (!class_exists('\\AKSRT\\User\\BanAppeal')) { + return []; + } + + $args = [ + 'meta_query' => [[ + 'key' => \AKSRT\User\BanAppeal::META_STATUS, + 'value' => ['pending', 'approved', 'rejected'], + 'compare' => 'IN', + ]], + 'number' => -1, + 'fields' => 'ID', + 'orderby' => 'ID', + 'order' => 'ASC', + 'count_total' => false, + 'update_meta_cache' => false, + ]; + if ($filters['send_user'] > 0) { + $args['include'] = [$filters['send_user']]; + } + + $user_ids = array_values(array_unique(array_map('absint', (array) get_users($args)))); + if (!$user_ids) { + return []; + } + if ($filters['s'] !== '') { + cache_users($user_ids); + } + update_meta_cache('user', $user_ids); + + $rows = []; + foreach ($user_ids as $user_id) { + $row = $this->get_ban_appeal_row($user_id); + if ($row === null) { + continue; + } + if ($filters['s'] !== '') { + $user = get_userdata($user_id); + $terms = [ + (string) $user_id, + (string) ($row['payload']['reason'] ?? ''), + (string) ($row['payload']['reason_key'] ?? ''), + $user instanceof \WP_User ? $user->display_name : '', + $user instanceof \WP_User ? $user->user_login : '', + $user instanceof \WP_User ? $user->user_email : '', + ]; + foreach ((array) ($row['payload']['data'] ?? []) as $key => $value) { + if (is_scalar($value)) { + $terms[] = (string) $key; + $terms[] = (string) $value; + } + } + if (!$this->ban_search_matches(implode(' ', $terms), $filters['s'])) { + continue; + } + } + $rows[] = $row; + } + return $rows; + } + + private function load_ban_report_notes(array &$items): void { + $report_ids = []; + foreach ($items as $row) { + if (($row['kind'] ?? '') === 'report' && (int) ($row['id'] ?? 0) > 0) { + $report_ids[] = (int) $row['id']; + } + } + $report_ids = array_values(array_unique($report_ids)); + if ($report_ids === [] || !class_exists('\\AKSRT\\Core\\DBMigrator')) { + return; + } + + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('report'); + if ($table === '') { + return; + } + $placeholders = implode(',', array_fill(0, count($report_ids), '%d')); + $report_notes = $wpdb->get_results($wpdb->prepare( + "SELECT id, notes_json FROM {$table} WHERE id IN ({$placeholders})", + $report_ids + ), ARRAY_A); + + $notes_by_id = []; + foreach ((array) $report_notes as $report) { + $notes = json_decode((string) ($report['notes_json'] ?? ''), true); + $notes_by_id[(int) ($report['id'] ?? 0)] = is_array($notes) ? $notes : []; + } + foreach ($items as &$row) { + if (($row['kind'] ?? '') === 'report') { + $row['payload']['notes'] = $notes_by_id[(int) $row['id']] ?? []; + } + } + unset($row); + } + + private function cache_ban_request_users(array $items, int $filter_user): void { + $user_ids = $filter_user > 0 ? [$filter_user] : []; + foreach ($items as $row) { + $user_id = (int) ($row['user_id'] ?? 0); + if ($user_id > 0) { + $user_ids[] = $user_id; + } + + $payload = (array) ($row['payload'] ?? []); + if (($row['kind'] ?? '') === 'report') { + if (($payload['target_type'] ?? '') === 'user' && (int) ($payload['target_id'] ?? 0) > 0) { + $user_ids[] = (int) $payload['target_id']; + } + foreach ((array) ($payload['notes'] ?? []) as $note) { + if (is_array($note) && isset($note['operator']) && is_scalar($note['operator'])) { + $user_ids[] = absint($note['operator']); + } + } + } elseif (isset($payload['handled_by']) && is_scalar($payload['handled_by'])) { + $user_ids[] = absint($payload['handled_by']); + } + } + + $user_ids = array_values(array_unique(array_filter(array_map('absint', $user_ids)))); + if ($user_ids === []) { + return; + } + cache_users($user_ids); + } + + private function find_ban_request(string $kind, int $id): ?array { + if ($id <= 0) { + return null; + } + if ($kind === 'appeal') { + return $this->get_ban_appeal_row($id); + } + if ($kind !== 'report' || !class_exists('\\AKSRT\\Core\\DBMigrator')) { + return null; + } + + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('report'); + if ($table === '') { + return null; + } + $report = $wpdb->get_row($wpdb->prepare( + "SELECT id, reporter_id, target_type, target_id, reason, category, status, created_at, updated_at, notes_json FROM {$table} WHERE id = %d LIMIT 1", + $id + ), ARRAY_A); + return is_array($report) ? $this->normalize_ban_report_row($report) : null; + } + + private function normalize_ban_report_row(array $report): ?array { + $raw_status = sanitize_key((string) ($report['status'] ?? '')); + $status = $raw_status === 'reviewing' ? 'pending' : $raw_status; + if (!in_array($status, ['pending', 'resolved', 'dismissed'], true)) { + return null; + } + + $id = (int) ($report['id'] ?? 0); + $reporter_id = (int) ($report['reporter_id'] ?? 0); + $notes = json_decode((string) ($report['notes_json'] ?? ''), true); + $payload = [ + 'id' => $id, + 'reporter_id' => $reporter_id, + 'target_type' => sanitize_key((string) ($report['target_type'] ?? '')), + 'target_id' => (int) ($report['target_id'] ?? 0), + 'reason' => (string) ($report['reason'] ?? ''), + 'category' => (string) ($report['category'] ?? ''), + 'status' => $raw_status, + 'created_at' => (string) ($report['created_at'] ?? ''), + 'updated_at' => (string) ($report['updated_at'] ?? ''), + 'notes' => is_array($notes) ? $notes : [], + ]; + + return [ + 'kind' => 'report', + 'id' => $id, + 'user_id' => $reporter_id, + 'status' => $status, + 'raw_status' => $raw_status, + 'time' => $payload['created_at'], + 'updated' => $payload['updated_at'], + 'payload' => $payload, + ]; + } + + private function get_ban_appeal_row(int $user_id): ?array { + if ($user_id <= 0 || !class_exists('\\AKSRT\\User\\BanAppeal')) { + return null; + } + + $raw_status = \AKSRT\User\BanAppeal::get_status($user_id); + $status = $raw_status === 'approved' ? 'resolved' : ($raw_status === 'rejected' ? 'dismissed' : $raw_status); + if (!in_array($status, ['pending', 'resolved', 'dismissed'], true)) { + return null; + } + + $payload = \AKSRT\User\BanAppeal::get_payload($user_id); + return [ + 'kind' => 'appeal', + 'id' => $user_id, + 'user_id' => $user_id, + 'status' => $status, + 'raw_status' => $raw_status, + 'time' => (string) ($payload['time'] ?? ''), + 'updated' => (string) ($payload['handled_at'] ?? ''), + 'payload' => $payload, + ]; + } + + private function ban_search_matches(string $haystack, string $needle): bool { + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + } + + /** @param array> $rows */ + private function sort_ban_requests(array &$rows, string $orderby, string $order): void { + $status_weight = ['pending' => 0, 'resolved' => 1, 'dismissed' => 2]; + usort($rows, static function (array $a, array $b) use ($orderby, $order, $status_weight): int { + if ($orderby === 'status') { + $comparison = ($status_weight[$a['status']] ?? 9) <=> ($status_weight[$b['status']] ?? 9); + } elseif ($orderby === 'type') { + $comparison = strcmp((string) $a['kind'], (string) $b['kind']); + } else { + $field = $orderby === 'updated' ? 'updated' : 'time'; + $comparison = strcmp((string) $a[$field], (string) $b[$field]); + } + + if ($comparison !== 0) { + return $order === 'ASC' ? $comparison : -$comparison; + } + + $kind_comparison = strcmp((string) $a['kind'], (string) $b['kind']); + return $kind_comparison !== 0 ? $kind_comparison : ((int) $a['id'] <=> (int) $b['id']); + }); + } +} diff --git a/src/Admin/Native/UsersMenusTickets.php b/src/Admin/Native/UsersMenusTickets.php deleted file mode 100644 index 0479fb42..00000000 --- a/src/Admin/Native/UsersMenusTickets.php +++ /dev/null @@ -1,538 +0,0 @@ -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'); - $process_action = isset($_GET['action']) ? sanitize_key((string) wp_unslash($_GET['action'])) : ''; - $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); - - NativeUi::card_open(); - echo '
'; - - // 类型筛选按钮组(对齐参考主题:全部 / 禁封申诉 / 用户举报) - 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 '
'; - - // 搜索(对齐参考主题:右侧搜索记录表单) - echo '
'; - echo ''; - if ($type !== '') { - echo ''; - } - echo ''; - submit_button(__('提交', 'aksrt'), 'primary', '', false); - 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) ? ' |' : ''; - $current = $status === $key ? ' class="current" aria-current="page"' : ''; - printf('
  • %s%s
  • ', $current, esc_url($url), esc_html($label), esc_html($sep)); - } - echo '
'; - - if ($s !== '') { - echo '

"' . esc_html($s) . '" ' . esc_html__('的搜索结果', 'aksrt') . '

'; - } - NativeUi::card_close(); - - $process_id = isset($_GET['id']) ? (string) wp_unslash($_GET['id']) : ''; - - // 处理视图不设“查看全部”链接(参考主题无此元素):重置过滤由顶部 - // 类型按钮组与状态 subsubsub 承担,工单行内直接展开处理表单。 - NativeUi::table_card_open(); - 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 NativeUi::empty_row(6, __('暂无申请记录', 'aksrt')); - } - - foreach ($tickets as $row) { - $process_key = (string) $row['kind'] . '-' . (string) $row['id']; - $expanded = $process_action === 'process' && $process_id === $process_key && $row['status'] === 'pending'; - $this->render_ticket_row($row, $page_url, $expanded); - - if ($row['status'] !== 'pending') { - continue; - } - - // 展开与收起走 JS 切换(无整页刷新,即点即达):展开时隐藏列表行、 - // 显示处理表单(信息只出现一次);取消时换回。无 JS 环境由立即处理 - // 链接(action=process)整页兜底,直链进入时表单默认展开。 - $hidden_attr = $expanded ? '' : ' style="display:none;"'; - echo ''; - } - - echo '
' . esc_html($thead['name']) . $orderby_html . '
'; - $this->render_ticket_process_form($row, $action_url); - echo '
'; - NativeUi::table_wrap_close(); - $this->ticket_pagenavi($total, self::TICKETS_PER_PAGE); - NativeUi::card_close(); - - // 行内展开/收起的轻量交互(管理员环境必有 jQuery)。 - echo ''; - } - - /** 分页(对齐参考主题 zibpay_admin_pagenavi:« ‹ 第X页共Y页 › » + 每页显示数) */ - private function ticket_pagenavi(int $total_count, int $per_page): void { - $current_page = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; - $total_pages = max(1, (int) ceil($total_count / max(1, $per_page))); - - $first_page_url = add_query_arg('paged', 1); - $last_page_url = add_query_arg('paged', $total_pages); - $prev_page_url = $current_page > 1 ? add_query_arg('paged', $current_page - 1) : '#'; - $next_page_url = $current_page < $total_pages ? add_query_arg('paged', $current_page + 1) : '#'; - - echo '
'; - echo '' . esc_html(sprintf(__('每页 %1$s 共%2$s', 'aksrt'), $per_page, $total_count)) . ''; - echo ''; - echo '«'; - echo '‹'; - echo '' . esc_html(sprintf(__('第 %1$s 页,共 ', 'aksrt'), $current_page)) . '' . esc_html((string) $total_pages) . '' . esc_html__(' 页', 'aksrt') . ''; - echo '›'; - echo '»'; - echo '

'; - } - - private function render_ticket_row(array $row, string $page_url, bool $hidden = false): void { - $kind = $row['kind']; - $status = $row['status']; - $user_id = (int) $row['user_id']; - $process_key = $kind . '-' . (string) $row['id']; - $url = add_query_arg(['action' => 'process', 'id' => $process_key], $page_url); - - // 状态列(对齐参考主题:待处理→立即处理按钮 / 已批准 / 处理完成 / 已拒绝 / 正在处理) - $is_processing = isset($_GET['action']) && sanitize_key((string) wp_unslash($_GET['action'])) === 'process' - && isset($_GET['id']) && (string) wp_unslash($_GET['id']) === $kind . '-' . (string) $row['id']; - if ($is_processing) { - $status_html = NativeUi::badge(__('正在处理', 'aksrt'), 'warning'); - } elseif ($status === 'pending') { - // JS 切换展开(无整页刷新);无 JS 时 href 整页兜底。 - $status_html = '' . esc_html__('立即处理', 'aksrt') . ''; - } elseif ($status === 'resolved') { - $status_html = $kind === 'appeal' - ? NativeUi::badge(__('已批准', 'aksrt'), 'success') - : NativeUi::badge(__('处理完成', 'aksrt'), 'info'); - } else { - $status_html = NativeUi::badge(__('已拒绝', 'aksrt'), 'danger'); - } - - // 类型列(对齐参考主题:不良举报红 / 禁封申诉绿) - $type_html = $kind === 'report' - ? NativeUi::badge(__('不良举报', 'aksrt'), 'danger', false) - : NativeUi::badge(__('禁封申诉', 'aksrt'), 'success', false); - - // 提交用户列(显示名链接 + 管理|查看 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); - - if ($target_type === 'user') { - $html = '
' . esc_html__('举报用户:', 'aksrt') . ($link !== '' ? $link : esc_html('#' . $target_id)) . '
'; - } else { - $html = '
' . esc_html__('举报内容:', 'aksrt') . ($link !== '' ? $link : esc_html($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, 'appeal_row'); - $html = $ban_html; - if (!empty($payload['reason'])) { - $html .= '
' . esc_html(sprintf(__('申诉说明:%s', 'aksrt'), (string) $payload['reason'])) . '
'; - } - return $html; - } - - /** - * 用户当前限制状态摘要(对齐参考主题禁封信息展示)。 - * - * @param string $context appeal_row(申诉列表行,已解封时自动结案)| form(处理表单) - */ - private function ticket_ban_info_html(int $user_id, string $context = 'form'): 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'))) . '
'; - } - - // 对齐参考主题:申诉列表行中用户已无禁封 → 显示已解封并自动结案 - if ($context === 'appeal_row' && class_exists('\AKSRT\User\BanAppeal') && \AKSRT\User\BanAppeal::get_status($user_id) === 'pending') { - update_user_meta($user_id, \AKSRT\User\BanAppeal::META_STATUS, 'approved'); - return '
' . esc_html__('此用户已解封', 'aksrt') . '
'; - } - - // 对齐参考主题:申诉处理表单中用户已解封的提示 - if ($context === 'appeal_form') { - return '
' . esc_html__('当前用户已解封', '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 '
'; - // admin-post.php 依赖 $_REQUEST['action'] 触发 admin_post_{action} 钩子; - // 缺失时 WordPress 不输出任何内容(白屏),这里按工单类型分发。 - 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 ''; - - 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 ''; - 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') . ''; - if (class_exists('\AKSRT\User\Restriction') && \AKSRT\User\Restriction::get_instance()->is_restricted($target_id)) { - echo $this->ticket_ban_info_html($target_id); - } else { - // 与参考主题一致:跳转到前台该用户公开主页,经封面右下角三点 - // 菜单的「禁封此用户」打开封禁弹窗完成处理(后台不提供禁封表单)。 - $ban_url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($target_id) : get_author_posts_url($target_id); - echo '
' . esc_html__('用户状态正常', 'aksrt') . '
'; - echo '

' . esc_html__('请查阅相关资料以及举证,如举报事实清楚,可以', 'aksrt'); - echo ' ' . esc_html__('点击此处', 'aksrt') . ' '; - echo esc_html__('对用户做禁封处理,并将处理结果填写至下方', 'aksrt') . '

'; - } - echo '
' . 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, 'appeal_form') . '
' . 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 '

'; - echo ' '; - // 取消:收起表单、换回列表行(JS 切换;无 JS 时整页回列表兜底)。 - echo '' . esc_html__('取消', 'aksrt') . ''; - echo '

'; - } -} diff --git a/tests/widget-system/ban-admin-contract.test.cjs b/tests/widget-system/ban-admin-contract.test.cjs new file mode 100644 index 00000000..6eec8849 --- /dev/null +++ b/tests/widget-system/ban-admin-contract.test.cjs @@ -0,0 +1,177 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +let fixture; + +function phpFixture() { + if (fixture) return fixture; + const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'ban-admin-harness.php')], { + cwd: root, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + fixture = JSON.parse(result.stdout); + return fixture; +} + +function inputValue(html, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = html.match(new RegExp(`]*name="${escaped}"[^>]*value="([^"]*)"`)); + return match ? match[1] : null; +} + +test('ban queue merges all records, reports status counts, and clamps pages above 100 rows', () => { + const result = phpFixture(); + + assert.equal(result.queue.total, 132); + assert.equal(result.queue.page, 2); + assert.equal(result.queue.items, 20); + assert.deepEqual(result.queue.counts, { all: 132, pending: 128, resolved: 2, dismissed: 2 }); + assert.equal(result.queue.overlarge_page, 7); + assert.equal(result.queue.overlarge_items, 12); + assert.deepEqual(result.filters, { + type: 'report', status: '', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC', + }); +}); + +test('real PHP rendering keeps review forms in templates and completed records read-only', () => { + const { html, filtered_html: filteredHtml } = phpFixture(); + + assert.equal((html.match(/\s*
\s*([\s\S]*?)<\/template>/)?.[1] || ''; + assert.notEqual(resolved, ''); + assert.doesNotMatch(resolved, /]*required>/); + assert.doesNotMatch(html, /]*checked/); +}); + +test('PHP templates escape all appeal data and report evidence while preserving safe links', () => { + const { html } = phpFixture(); + + assert.match(html, /<script>alert\(1\)<\/script>/); + assert.match(html, /<img src=x onerror=alert\(1\)>/); + assert.match(html, /Name <img src=x onerror=alert\(4\)>/); + assert.match(html, /Applicant <b>value<\/b>/); + assert.match(html, /reason <svg onload=alert\(1\)>/); + assert.match(html, /href="https:\/\/evidence\.example\.test\/proof\.jpg" target="_blank" rel="noopener noreferrer"/); + assert.match(html, /href="https:\/\/appeal\.example\.test\/file\.pdf" target="_blank" rel="noopener noreferrer"/); + assert.match(html, /
https:\/\/appeal\.example\.test\/document\.pdf<\/dd>/); + assert.doesNotMatch(html, /href="javascript:/i); + assert.doesNotMatch(html, /alert\(/i); +}); + +test('dedicated ban assets enqueue only on the matching native page', () => { + const { assets } = phpFixture(); + + assert.equal(assets.styles.length, 1); + assert.deepEqual(assets.styles[0].slice(0, 3), [ + 'aksrt-admin-user-ban', 'https://theme.example.test/assets/css/admin/user-ban.css', ['aksrt-admin-native-ui'], + ]); + assert.equal(assets.scripts.length, 1); + assert.deepEqual(assets.scripts[0].slice(0, 3), [ + 'aksrt-admin-user-ban', 'https://theme.example.test/assets/js/admin-user-ban.js', [], + ]); + assert.equal(assets.scripts[0][4], true); +}); + +test('PHP actions reject blank notes and stale versions, and preserve filter roundtrips', () => { + const { actions } = phpFixture(); + + assert.equal(actions.blank_report.message, 'report_failed'); + assert.match(actions.blank_report.args.aksrt_ban_error, /必须填写处理说明/); + assert.equal(actions.stale_report.message, 'report_failed'); + assert.match(actions.stale_report.args.aksrt_ban_error, /已变化或已处理/); + assert.equal(actions.valid_report.message, 'report_updated'); + assert.deepEqual({ + type: actions.valid_report.args.type, + status: actions.valid_report.args.status, + s: actions.valid_report.args.s, + send_user: actions.valid_report.args.send_user, + orderby: actions.valid_report.args.orderby, + order: actions.valid_report.args.order, + paged: actions.valid_report.args.paged, + }, { type: 'report', status: 'pending', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC', paged: 2 }); + + assert.equal(actions.blank_appeal.message, 'appeal_failed'); + assert.match(actions.blank_appeal.args.aksrt_ban_error, /必须填写拒绝原因/); + assert.equal(actions.valid_appeal.message, 'appeal_rejected'); + assert.equal(actions.appeal_status, 'rejected'); + assert.equal(actions.appeal_payload.admin_note, 'Missing evidence'); + assert.equal(actions.approve_appeal.message, 'appeal_approved'); + assert.equal(actions.approved_status, 'approved'); + assert.equal(actions.restrictions_lifted, 1); + assert.deepEqual(actions.report_calls, [[9001, 'resolved', 'Reviewed']]); + assert.equal(actions.messages.length, 2); +}); + +test('ban admin contract keeps the single-dialog flow and separate native operations', () => { + const users = read('src/Admin/Native/UsersMenus.php'); + const page = read('src/Admin/Native/UsersMenusBan.php'); + const query = read('src/Admin/Native/UsersMenusBanQuery.php'); + const actions = read('src/Admin/Native/UsersMenusBanActions.php'); + const dialogs = read('src/Admin/Native/UsersMenusBanDialogs.php'); + const script = read('assets/js/admin-user-ban.js'); + const css = read('assets/css/admin/user-ban.css'); + + assert.match(users, /use UsersMenusBan;/); + assert.match(users, /admin_post_aksrt_users_update_report/); + assert.match(users, /admin_post_aksrt_users_process_appeal/); + assert.doesNotMatch(users, /use UsersMenusTickets;/); + assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php'))); + assert.match(page, /if \(\$hook !== 'users_page_aksrt_user_ban'\) return/); + assert.match(page, /ban_record_version\(\$row\), \$submitted\)/); + assert.match(actions, /\$this->ban_request_is_current\(\$row, 'pending'\)/); + assert.match(actions, /\$decision === 'reject' && \$note === ''/); + assert.match(actions, /\$new_status === 'dismissed' && \$note === ''/); + + assert.match(script, /dialog\.showModal\(\)/); + assert.match(script, /dialog\.replaceChildren\(\)/); + assert.match(script, /previousOpener\.focus\(\)/); + assert.match(script, /window\.addEventListener\('pageshow'/); + assert.match(css, /html\.aksrt-ban-modal-open \{ overflow: hidden/); +}); diff --git a/tests/widget-system/ban-admin-harness.php b/tests/widget-system/ban-admin-harness.php new file mode 100644 index 00000000..64092516 --- /dev/null +++ b/tests/widget-system/ban-admin-harness.php @@ -0,0 +1,383 @@ + 0 ? 1 : 0; } + public function get_user_restrictions(int $user_id, bool $active = false): array { unset($user_id, $active); return []; } + } + + final class BanAppeal { + public const META_STATUS = '_aksrt_ban_appeal_status'; + public const META_PAYLOAD = '_aksrt_ban_appeal_payload'; + public const META_HISTORY = '_aksrt_ban_appeal_history'; + public static function get_status(int $user_id): string { + return (string) ($GLOBALS['aksrt_test_user_meta'][$user_id][self::META_STATUS] ?? ''); + } + public static function get_payload(int $user_id): array { + $value = $GLOBALS['aksrt_test_user_meta'][$user_id][self::META_PAYLOAD] ?? []; + return is_array($value) ? $value : []; + } + } +} + +namespace AKSRT\Messages { + final class ReportCenter { + private static ?self $instance = null; + public static function get_instance(): self { return self::$instance ??= new self(); } + public function update_status(int $id, string $status, string $note): bool { + $GLOBALS['aksrt_test_reports'][$id]['status'] = $status; + $GLOBALS['aksrt_test_reports'][$id]['updated_at'] = '2026-10-30 13:00:00'; + $GLOBALS['aksrt_test_report_actions'][] = [$id, $status, $note]; + return true; + } + } + + final class Message { + private static ?self $instance = null; + public static function get_instance(): self { return self::$instance ??= new self(); } + public function send(int $user_id, string $type, string $message, array $args = []): void { + $GLOBALS['aksrt_test_messages'][] = [$user_id, $type, $message, $args]; + } + } +} + +namespace AKSRT\Admin\Native { + final class AdminListPagination { + public static function render(string $slug, int $total, int $page, int $per_page, array $filters = [], string $parent = ''): void { + $GLOBALS['aksrt_test_pagination'][] = [$slug, $total, $page, $per_page, $filters, $parent]; + echo ''; + } + } +} + +namespace { + define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR); + define('AKSRT_DIR', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR); + define('AKSRT_URI', 'https://theme.example.test'); + define('ARRAY_A', 'ARRAY_A'); + + class WP_User { + public function __construct(public int $ID, public string $display_name) {} + } + + final class TestRedirect extends \RuntimeException { + public function __construct(public string $page, public string $redirectMessage, public array $args) { + parent::__construct($redirectMessage); + } + } + + final class TestWpdb { + public string $prefix = 'wp_'; + public string $users = 'wp_users'; + public string $last_error = ''; + private array $args = []; + public function prepare(string $query, $args = []): string { + $this->args = is_array($args) ? $args : [$args]; + return $query; + } + public function esc_like(string $text): string { return addcslashes($text, '_%\\'); } + public function get_results(string $query, $output = null): array { + if (str_contains($query, 'notes_json')) { + return array_map(static fn (int $id): array => [ + 'id' => $id, + 'notes_json' => $GLOBALS['aksrt_test_reports'][$id]['notes_json'] ?? '[]', + ], $this->args); + } + if (str_contains($query, 'aksrt_user_restriction')) { + return $GLOBALS['aksrt_test_restrictions']; + } + $reports = array_values($GLOBALS['aksrt_test_reports']); + $search = ''; + $send_user = 0; + foreach ($this->args as $arg) { + if (is_string($arg) && str_starts_with($arg, '%') && str_ends_with($arg, '%')) $search = trim($arg, '%'); + if (is_int($arg) && $arg > 0) $send_user = $arg; + } + return array_values(array_filter($reports, static function (array $row) use ($search, $send_user): bool { + if ($send_user > 0 && (int) $row['reporter_id'] !== $send_user) return false; + if ($search !== '' && stripos(implode(' ', [$row['reason'], $row['category'], $row['target_type'], $row['target_id']]), $search) === false) return false; + return true; + })); + } + public function get_row(string $query, $output = null) { + $id = (int) ($this->args[0] ?? 0); + return $GLOBALS['aksrt_test_reports'][$id] ?? null; + } + } + + $GLOBALS['wpdb'] = new TestWpdb(); + $GLOBALS['aksrt_test_reports'] = []; + $GLOBALS['aksrt_test_user_meta'] = []; + $GLOBALS['aksrt_test_restrictions'] = [[ + 'user_id' => 7, + 'type' => 'ban', + 'scope' => 'site', + 'reason' => 'fixture restriction', + 'created_at' => '2026-10-01 08:00:00', + 'expires_at' => null, + ]]; + $GLOBALS['aksrt_test_assets'] = ['styles' => [], 'scripts' => []]; + $GLOBALS['aksrt_test_pagination'] = []; + $GLOBALS['aksrt_test_report_actions'] = []; + $GLOBALS['aksrt_test_messages'] = []; + $GLOBALS['aksrt_test_nonce_checks'] = []; + $GLOBALS['aksrt_test_capability'] = true; + + function __($text, $domain = null): string { return (string) $text; } + function esc_html__($text, $domain = null): string { return esc_html((string) $text); } + function esc_attr__($text, $domain = null): string { return esc_attr((string) $text); } + function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + function esc_attr($value): string { return esc_html((string) $value); } + function esc_url($url, array $protocols = ['http', 'https']): string { + $url = trim((string) $url); + if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $url, $match) && !in_array(strtolower($match[1]), $protocols, true)) return ''; + return esc_attr($url); + } + function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; } + function sanitize_text_field($value): string { return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? ''); } + function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); } + function wp_unslash($value) { return $value; } + function wp_slash($value) { return $value; } + function absint($value): int { return abs((int) $value); } + function admin_url(string $path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); } + function add_query_arg($key, $value = null, $url = ''): string { + if (is_array($key)) { + $args = $key; + $base = (string) $value; + } else { + $args = [(string) $key => $value]; + $base = (string) $url; + } + $parts = parse_url($base); + $query = []; + if (!empty($parts['query'])) parse_str($parts['query'], $query); + $query = array_merge($query, $args); + $prefix = isset($parts['scheme']) ? $parts['scheme'] . '://' . ($parts['host'] ?? '') : ''; + $prefix .= $parts['path'] ?? ''; + return $prefix . '?' . http_build_query($query); + } + function current_user_can(string $capability): bool { return $capability === 'manage_options' && $GLOBALS['aksrt_test_capability']; } + function wp_die($message): void { throw new \RuntimeException((string) $message); } + function wp_nonce_field(string $action): void { echo ''; } + function check_admin_referer(string $action): void { + $GLOBALS['aksrt_test_nonce_checks'][] = $action; + if (($_POST['_wpnonce'] ?? '') !== $action . '-nonce') throw new \RuntimeException('nonce mismatch'); + } + function wp_enqueue_style(string $handle, string $src, array $deps = [], string $version = ''): void { + $GLOBALS['aksrt_test_assets']['styles'][] = [$handle, $src, $deps, $version]; + } + function wp_enqueue_script(string $handle, string $src, array $deps = [], string $version = '', bool $footer = false): void { + $GLOBALS['aksrt_test_assets']['scripts'][] = [$handle, $src, $deps, $version, $footer]; + } + function get_userdata(int $user_id) { return $user_id > 0 ? new WP_User($user_id, $user_id === 7 ? 'Report ' : 'User ' . $user_id) : false; } + function get_current_user_id(): int { return 999; } + function get_user_meta(int $user_id, string $key, bool $single = false) { return $GLOBALS['aksrt_test_user_meta'][$user_id][$key] ?? ''; } + function update_user_meta(int $user_id, string $key, $value): bool { $GLOBALS['aksrt_test_user_meta'][$user_id][$key] = $value; return true; } + function delete_user_meta(int $user_id, string $key): bool { unset($GLOBALS['aksrt_test_user_meta'][$user_id][$key]); return true; } + function get_users(array $args = []): array { + $ids = array_keys($GLOBALS['aksrt_test_user_meta']); + if (isset($args['include'])) $ids = array_values(array_intersect($ids, $args['include'])); + $ids = array_values(array_filter($ids, static fn (int $id): bool => \AKSRT\User\BanAppeal::get_status($id) !== '')); + return $args['fields'] === 'ID' ? $ids : array_map('get_userdata', $ids); + } + function update_meta_cache(string $type, array $ids): void { unset($type, $ids); } + function cache_users(array $ids): void { unset($ids); } + function _prime_post_caches(array $ids, bool $update_term_cache = true, bool $update_meta_cache = true): void { unset($ids, $update_term_cache, $update_meta_cache); } + function _prime_comment_caches(array $ids, bool $update_meta_cache = true): void { unset($ids, $update_meta_cache); } + function get_date_from_gmt(string $date, string $format): string { return $date; } + function current_time(string $type, bool $gmt = false): string { return '2026-10-30 12:00:00'; } + function number_format_i18n($number): string { return number_format((float) $number); } + function get_avatar_url(int $user_id, array $args = []): string { return 'https://example.test/avatar/' . $user_id . '.png'; } + function get_edit_user_link(int $user_id): string { return admin_url('user-edit.php?user_id=' . $user_id); } + function get_post(int $id) { return $id === 44 ? (object) ['ID' => $id] : false; } + function get_the_title(int $id): string { return $id === 44 ? '' : 'Post ' . $id; } + function get_permalink(int $id): string { return 'https://content.example.test/post/' . $id; } + function get_comment(int $id) { return false; } + function get_comment_link(int $id): string { return 'https://content.example.test/comment/' . $id; } + function get_author_posts_url(int $user_id): string { return 'https://content.example.test/author/' . $user_id; } + function aksrt_get_user_home_url(int $user_id): string { return 'https://content.example.test/user/' . $user_id; } + function do_action(string $hook, ...$args): void { unset($hook, $args); } + + function fixture_report(int $id, int $reporter, string $status, string $time, string $reason = ''): array { + return [ + 'id' => $id, + 'reporter_id' => $reporter, + 'target_type' => 'post', + 'target_id' => 44, + 'reason' => $reason !== '' ? $reason : 'report reason ' . $id, + 'category' => 'other', + 'status' => $status, + 'created_at' => $time, + 'updated_at' => $time, + 'notes_json' => '[]', + ]; + } + + for ($id = 1; $id <= 110; $id++) { + $status = $id === 2 ? 'resolved' : ($id === 3 ? 'dismissed' : ($id === 4 ? 'reviewing' : 'pending')); + $time = gmdate('Y-m-d H:i:s', strtotime('2026-10-01 00:00:00 UTC') + ($id * 60)); + $reason = $id === 1 + ? 'Evidence https://evidence.example.test/proof.jpg javascript:alert(2)' + : 'report reason ' . $id; + $time = $id === 1 ? '2026-10-30 12:00:00' : ($id === 2 ? '2026-10-29 11:00:00' : $time); + $GLOBALS['aksrt_test_reports'][$id] = fixture_report($id, $id === 1 ? 7 : 20 + $id, $status, $time, $reason); + } + $GLOBALS['aksrt_test_reports'][9001] = fixture_report(9001, 7, 'pending', '2026-09-01 09:00:00', 'needle action report'); + $GLOBALS['aksrt_test_reports'][9002] = fixture_report(9002, 7, 'pending', '2026-09-02 09:00:00', 'needle stale report'); + + function fixture_appeal(int $user_id, string $status, string $time, array $payload = []): void { + $GLOBALS['aksrt_test_user_meta'][$user_id][\AKSRT\User\BanAppeal::META_STATUS] = $status; + $GLOBALS['aksrt_test_user_meta'][$user_id][\AKSRT\User\BanAppeal::META_PAYLOAD] = array_merge([ + 'time' => $time, + 'reason_key' => 'ban reason', + 'reason' => 'appeal explanation', + 'data' => [], + ], $payload); + } + fixture_appeal(501, 'pending', '2026-10-29 12:00:00', [ + 'reason_key' => 'reason ', + 'reason' => 'Appeal note https://appeal.example.test/file.pdf javascript:alert(3)', + 'data' => [ + 'Name ' => 'Applicant value', + 'Evidence' => 'https://appeal.example.test/document.pdf', + 'Ignored array' => ['nested'], + ], + ]); + fixture_appeal(502, 'approved', '2026-10-20 09:00:00', ['handled_at' => '2026-10-21 09:00:00', 'handled_by' => 999, 'decision' => 'approved']); + fixture_appeal(503, 'rejected', '2026-10-19 09:00:00', ['handled_at' => '2026-10-20 09:00:00', 'handled_by' => 999, 'decision' => 'rejected']); + for ($user_id = 600; $user_id < 615; $user_id++) fixture_appeal($user_id, 'pending', '2026-10-02 ' . sprintf('%02d', $user_id - 600) . ':00:00'); + fixture_appeal(7001, 'pending', '2026-09-03 09:00:00', ['reason' => 'pending appeal action']); + fixture_appeal(7002, 'pending', '2026-09-04 09:00:00', ['reason' => 'pending appeal approve']); + + require_once AKSRT_DIR . 'src/Admin/Native/NativeUi.php'; + require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanQuery.php'; + require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanDialogs.php'; + require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBanActions.php'; + require_once AKSRT_DIR . 'src/Admin/Native/UsersMenusBan.php'; + + final class BanFixtureMenu { + use \AKSRT\Admin\Native\UsersMenusBan; + public const CAPABILITY = 'manage_options'; + public function ensure_cap(): void {} + public function flash(): void {} + public function redirect(string $page, string $message, array $args = []): void { throw new TestRedirect($page, $message, $args); } + public function record(string $kind, int $id): ?array { return $this->find_ban_request($kind, $id); } + public function filters(array $input): array { return $this->get_ban_filters($input); } + public function version(array $row): string { return $this->ban_record_version($row); } + public function query(array $filters, int $page): array { + return $this->query_ban_requests($this->get_ban_filters($filters), $page); + } + public function query_record(string $kind, int $id): ?array { + $filters = $this->get_ban_filters(['type' => $kind, 'status' => '']); + $firstPage = $this->query_ban_requests($filters, 1); + $pages = max(1, (int) ceil($firstPage['total'] / 20)); + for ($page = 1; $page <= $pages; $page++) { + $result = $page === 1 ? $firstPage : $this->query_ban_requests($filters, $page); + foreach ($result['items'] as $row) if ($row['kind'] === $kind && (int) $row['id'] === $id) return $row; + } + return null; + } + } + + function render_ban_page(array $get): string { + $_GET = $get; + ob_start(); + (new BanFixtureMenu())->render_ban_page(); + return (string) ob_get_clean(); + } + + function call_action(BanFixtureMenu $menu, string $method, string $nonce_action, array $post): array { + $_POST = array_merge($post, ['_wpnonce' => $nonce_action . '-nonce']); + try { + $menu->{$method}(); + return ['redirected' => false]; + } catch (TestRedirect $redirect) { + return ['redirected' => true, 'page' => $redirect->page, 'message' => $redirect->redirectMessage, 'args' => $redirect->args]; + } + } + + $queueMenu = new BanFixtureMenu(); + $all = $queueMenu->query(['status' => ''], 2); + $overlargePage = $queueMenu->query(['status' => ''], 99); + $filters = $queueMenu->filters([ + 'type' => 'report', 'status' => '', 's' => 'needle', 'send_user' => '7', + 'orderby' => 'updated', 'order' => 'ASC', + ]); + $html = render_ban_page(['page' => 'aksrt_user_ban', 'status' => '', 'orderby' => 'time', 'order' => 'DESC']); + $filteredHtml = render_ban_page([ + 'page' => 'aksrt_user_ban', 'type' => 'report', 'status' => 'pending', 's' => 'needle', + 'send_user' => '7', 'orderby' => 'updated', 'order' => 'ASC', 'paged' => '1', + ]); + + $GLOBALS['aksrt_test_assets'] = ['styles' => [], 'scripts' => []]; + $assetMenu = new BanFixtureMenu(); + $assetMenu->enqueue_ban_assets('users_page_aksrt_user_ban'); + $assetMenu->enqueue_ban_assets('users_page_other'); + + $menu = new BanFixtureMenu(); + $reportQueryRow = $menu->query_record('report', 9001); + $reportVersion = $menu->version($reportQueryRow); + $reportBase = [ + 'report_id' => '9001', 'new_status' => 'resolved', 'note' => 'Reviewed', 'record_version' => $reportVersion, + 'filter_type' => 'report', 'filter_status' => 'pending', 's' => 'needle', 'send_user' => '7', + 'orderby' => 'updated', 'order' => 'ASC', 'paged' => '2', + ]; + $blankReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9001', array_merge($reportBase, ['new_status' => 'dismissed', 'note' => ' '])); + $staleReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9002', array_merge($reportBase, ['report_id' => '9002', 'record_version' => 'stale-version'])); + $validReport = call_action($menu, 'handle_update_report', 'aksrt_users_update_report_9001', $reportBase); + + $appealRow = $menu->query_record('appeal', 7001); + $appealVersion = $menu->version($appealRow); + $appealBase = [ + 'user_id' => '7001', 'decision' => 'reject', 'note' => ' Missing evidence ', 'record_version' => $appealVersion, + 'filter_type' => 'appeal', 'filter_status' => 'pending', 's' => 'needle', 'send_user' => '7001', + 'orderby' => 'updated', 'order' => 'ASC', 'paged' => '3', + ]; + $blankAppeal = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7001', array_merge($appealBase, ['note' => ' '])); + $validAppeal = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7001', $appealBase); + + $approveRow = $menu->query_record('appeal', 7002); + $approve = call_action($menu, 'handle_process_appeal', 'aksrt_users_process_appeal_7002', [ + 'user_id' => '7002', 'decision' => 'approve', 'note' => '', 'record_version' => $menu->version($approveRow), + ]); + + $filtersHtml = render_ban_page(['page' => 'aksrt_user_ban', 'type' => 'report', 'status' => 'pending', 's' => 'needle', 'send_user' => '7', 'orderby' => 'updated', 'order' => 'ASC']); + + echo json_encode([ + 'queue' => [ + 'total' => $all['total'], 'page' => $all['page'], 'counts' => $all['counts'], 'items' => count($all['items']), + 'overlarge_page' => $overlargePage['page'], 'overlarge_items' => count($overlargePage['items']), + ], + 'filters' => $filters, + 'html' => $html, + 'filtered_html' => $filteredHtml, + 'assets' => $GLOBALS['aksrt_test_assets'], + 'actions' => [ + 'blank_report' => $blankReport, 'stale_report' => $staleReport, 'valid_report' => $validReport, + 'blank_appeal' => $blankAppeal, 'valid_appeal' => $validAppeal, 'approve_appeal' => $approve, + 'report_calls' => $GLOBALS['aksrt_test_report_actions'], 'messages' => $GLOBALS['aksrt_test_messages'], + 'appeal_status' => \AKSRT\User\BanAppeal::get_status(7001), + 'appeal_payload' => \AKSRT\User\BanAppeal::get_payload(7001), + 'approved_status' => \AKSRT\User\BanAppeal::get_status(7002), + 'restrictions_lifted' => \AKSRT\User\Restriction::$lifted, + ], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); +} diff --git a/tests/widget-system/ban-admin-runtime.test.cjs b/tests/widget-system/ban-admin-runtime.test.cjs new file mode 100644 index 00000000..506e9b9e --- /dev/null +++ b/tests/widget-system/ban-admin-runtime.test.cjs @@ -0,0 +1,260 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const rootDir = path.resolve(__dirname, '..', '..'); + +function runtime({ completed = false } = {}) { + const ids = new Map(); + const doc = { activeElement: null }; + + class Element { + constructor(attrs = {}) { + this.attrs = { ...attrs }; + this.listeners = {}; + this.queries = new Map(); + this.dataset = {}; + this.disabled = false; + this.open = false; + this.required = false; + this.checked = Boolean(attrs.checked); + this.value = attrs.value || ''; + this.textContent = attrs.textContent || ''; + this.validity = ''; + this.isConnected = true; + this.currentContent = null; + const names = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean)); + this.classList = { + add(name) { names.add(name); }, + remove(name) { names.delete(name); }, + toggle(name, force) { + const enabled = force === undefined ? !names.has(name) : Boolean(force); + if (enabled) names.add(name); else names.delete(name); + return enabled; + }, + contains(name) { return names.has(name); }, + }; + } + getAttribute(name) { return this.attrs[name] ?? null; } + setAttribute(name, value) { this.attrs[name] = String(value); } + removeAttribute(name) { delete this.attrs[name]; } + addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); } + fire(type, values = {}) { + const event = { + target: this, + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; }, + ...values, + }; + (this.listeners[type] || []).forEach((callback) => callback(event)); + return event; + } + querySelector(selector) { + const matches = this.querySelectorAll(selector); + return matches[0] || null; + } + querySelectorAll(selector) { + const mapped = this.queries.get(selector); + if (mapped) return Array.isArray(mapped) ? mapped : [mapped]; + if (this.currentContent) return this.currentContent.querySelectorAll(selector); + return []; + } + map(selector, elements) { this.queries.set(selector, elements); return this; } + closest(selector) { + if (selector === '[data-aksrt-dialog-close]' && this.attrs['data-aksrt-dialog-close'] !== undefined) return this; + return null; + } + focus() { doc.activeElement = this; } + setCustomValidity(message) { this.validity = message; } + replaceChildren(content = null) { this.currentContent = content; } + getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; } + } + + function fragment({ readOnly = false, initiallyChecked = false } = {}) { + const part = new Element(); + const title = new Element({ id: 'aksrt-ban-dialog-title', autofocus: '' }); + const closeIcon = new Element({ 'data-aksrt-dialog-close': '' }); + const closeFooter = new Element({ 'data-aksrt-dialog-close': '' }); + part.map('#aksrt-ban-dialog-title', title) + .map('[data-aksrt-dialog-close]', [closeIcon, closeFooter]); + const controls = [closeIcon, closeFooter]; + let form = null; + let radios = []; + let note = null; + let submit = null; + if (!readOnly) { + radios = [ + new Element({ type: 'radio', value: 'approve', required: '', checked: initiallyChecked }), + new Element({ type: 'radio', value: 'reject', required: '', checked: initiallyChecked }), + ]; + note = new Element({ name: 'note', value: 'old note', 'data-required-error': 'Please enter a nonblank rejection reason' }); + const label = new Element({ 'data-aksrt-ban-note-label': '', 'data-optional': 'Optional note', 'data-required': 'Reject reason required' }); + submit = new Element({ 'data-aksrt-ban-submit': '', 'data-idle-label': 'Choose decision', 'data-approve-label': 'Confirm approval', 'data-reject-label': 'Confirm rejection', 'data-busy-label': 'Submitting' }); + controls.push(submit); + form = new Element(); + form.dataset = {}; + form.elements = { radios, note }; + form.map('input[type="radio"]', radios) + .map('textarea[name="note"]', note) + .map('[data-aksrt-ban-note-label]', label) + .map('[data-aksrt-ban-submit]', submit); + form.reportValidity = () => { + const hasRequiredRadio = radios.some((radio) => radio.required); + const radioValid = !hasRequiredRadio || radios.some((radio) => radio.checked); + return radioValid && !note.validity && (!note.required || note.value.trim() !== ''); + }; + part.map('form[data-aksrt-ban-review]', form); + } + part.map('[data-aksrt-ban-submit], [data-aksrt-dialog-close]', controls); + return { part, form, radios, note, submit, title, closeIcon, closeFooter }; + } + + const root = new Element(); + const window = new Element(); + doc.documentElement = new Element(); + doc.querySelector = (selector) => selector === '.aksrt-ban-page' ? root : null; + doc.getElementById = (id) => ids.get(id) || null; + const dialog = new Element(); + const originalDialogQuery = dialog.querySelector.bind(dialog); + const originalDialogQueryAll = dialog.querySelectorAll.bind(dialog); + dialog.querySelector = (selector) => dialog.currentContent ? dialog.currentContent.querySelector(selector) : originalDialogQuery(selector); + dialog.querySelectorAll = (selector) => dialog.currentContent ? dialog.currentContent.querySelectorAll(selector) : originalDialogQueryAll(selector); + dialog.showModal = () => { + dialog.open = true; + const title = dialog.querySelector('#aksrt-ban-dialog-title'); + if (title) title.focus(); + }; + dialog.close = () => { + if (!dialog.open) return; + dialog.open = false; + dialog.fire('close'); + }; + ids.set('aksrt-ban-dialog', dialog); + + const opener = new Element({ 'data-aksrt-ban-view': 'report-1' }); + const readonlyOpener = new Element({ 'data-aksrt-ban-view': 'report-2' }); + const templates = new Map(); + templates.set('aksrt-ban-detail-report-1', { + content: { cloneNode: () => fragment().part }, + }); + templates.set('aksrt-ban-detail-report-2', { + content: { cloneNode: () => fragment({ readOnly: true }).part }, + }); + ids.set('aksrt-ban-detail-report-1', templates.get('aksrt-ban-detail-report-1')); + ids.set('aksrt-ban-detail-report-2', templates.get('aksrt-ban-detail-report-2')); + root.map('[data-aksrt-ban-view]', [opener, readonlyOpener]); + + vm.runInNewContext(fs.readFileSync(path.join(rootDir, 'assets/js/admin-user-ban.js'), 'utf8'), { document: doc, window }); + return { doc, window, root, dialog, opener, readonlyOpener }; +} + +function openReview(runtimeState, button = runtimeState.opener) { + button.fire('click'); + const frame = runtimeState.dialog.currentContent; + const form = frame.querySelector('form[data-aksrt-ban-review]'); + return { + frame, + form, + radios: form.querySelectorAll('input[type="radio"]'), + note: form.querySelector('textarea[name="note"]'), + label: form.querySelector('[data-aksrt-ban-note-label]'), + submit: form.querySelector('[data-aksrt-ban-submit]'), + closeButtons: frame.querySelectorAll('[data-aksrt-ban-submit], [data-aksrt-dialog-close]').filter((button) => button.attrs['data-aksrt-dialog-close'] !== undefined), + }; +} + +test('opening review clears choices, focuses the dialog, and locks page scrolling', () => { + const r = runtime(); + const review = openReview(r); + + assert.equal(r.dialog.open, true); + assert.equal(r.doc.activeElement, review.frame.querySelector('#aksrt-ban-dialog-title')); + assert.deepEqual(review.radios.map((radio) => radio.checked), [false, false]); + assert.equal(review.note.value, ''); + assert.equal(review.submit.disabled, true); + assert.equal(review.label.textContent, 'Optional note'); + assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true); +}); + +test('reject decisions require a nonblank trimmed note before submission', () => { + const r = runtime(); + const review = openReview(r); + review.radios[1].checked = true; + review.form.fire('change'); + + assert.equal(review.note.required, true); + assert.equal(review.label.textContent, 'Reject reason required'); + assert.equal(review.submit.disabled, false); + review.note.value = ' \n '; + review.form.fire('input'); + assert.notEqual(review.note.validity, ''); + const invalid = review.form.fire('submit'); + assert.equal(invalid.defaultPrevented, true); + assert.equal(review.form.dataset.submitting, undefined); + + review.note.value = 'Specific reason'; + review.form.fire('input'); + assert.equal(review.note.validity, ''); + const valid = review.form.fire('submit'); + assert.equal(valid.defaultPrevented, false); + assert.equal(review.form.dataset.submitting, '1'); + assert.equal(review.submit.textContent, 'Submitting'); +}); + +test('submission blocks duplicate posts and closing until pageshow restores state', () => { + const r = runtime(); + const review = openReview(r); + review.radios[0].checked = true; + review.form.fire('change'); + review.form.fire('submit'); + + assert.equal(review.submit.disabled, true); + assert.ok(review.closeButtons.every((button) => button.disabled)); + assert.equal(r.dialog.open, true); + assert.equal(review.form.fire('submit').defaultPrevented, true); + r.dialog.fire('cancel'); + r.dialog.fire('click', { target: review.closeButtons[0] }); + assert.equal(r.dialog.open, true); + + r.window.fire('pageshow'); + assert.equal(review.form.dataset.submitting, undefined); + assert.equal(review.form.getAttribute('aria-busy'), null); + assert.equal(review.submit.disabled, false); + assert.ok(review.closeButtons.every((button) => !button.disabled)); + assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true); + + r.dialog.fire('click', { target: review.closeButtons[0] }); + assert.equal(r.dialog.open, false); + assert.equal(r.dialog.currentContent, null); + assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false); + assert.equal(r.doc.activeElement, r.opener); +}); + +test('completed records open read-only without a form or submit controls', () => { + const r = runtime(); + r.readonlyOpener.fire('click'); + + assert.equal(r.dialog.open, true); + assert.equal(r.dialog.querySelector('form[data-aksrt-ban-review]'), null); + assert.equal(r.dialog.querySelector('[data-aksrt-ban-submit]'), null); + assert.ok(r.dialog.querySelector('[data-aksrt-dialog-close]')); + r.dialog.fire('click', { target: r.dialog.querySelector('[data-aksrt-dialog-close]') }); + assert.equal(r.dialog.open, false); + assert.equal(r.doc.activeElement, r.readonlyOpener); +}); + +test('backdrop closes only when the pointer starts and ends outside the dialog', () => { + const r = runtime(); + openReview(r); + r.dialog.fire('pointerdown', { clientX: 120, clientY: 120 }); + r.dialog.fire('click', { clientX: 0, clientY: 0 }); + assert.equal(r.dialog.open, true); + r.dialog.fire('pointerdown', { clientX: 0, clientY: 0 }); + r.dialog.fire('click', { clientX: 0, clientY: 0 }); + assert.equal(r.dialog.open, false); + assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false); +}); diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index ddd4c234..4fcca6be 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -145,10 +145,13 @@ test('ban/report settings page mirrors the reference theme schema', () => { }) test('ban appeal approval actually lifts restrictions', () => { - assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/) - assert.ok(!usersMenus.includes("SET status = 'lifted'"), 'restriction table has no status column') - assert.ok(!usersMenus.includes('clear_user_bans')) - assert.ok(!usersMenus.includes('lift_ban')) + const banActions = read('src/Admin/Native/UsersMenusBanActions.php') + assert.match(banActions, /lift_user_restriction\(\$user_id\)/) + for (const source of [usersMenus, banActions]) { + assert.ok(!source.includes("SET status = 'lifted'"), 'restriction table has no status column') + assert.ok(!source.includes('clear_user_bans')) + assert.ok(!source.includes('lift_ban')) + } }) test('ban identity badge occupies the public profile meta row', () => { @@ -327,71 +330,67 @@ test('admin report notification links to the real page', () => { assert.match(reportCenter, /users\.php\?page=aksrt_user_ban&type=report&status=pending/) }) -test('native ban page mirrors the reference theme layout', () => { - const tickets = read('src/Admin/Native/UsersMenusTickets.php') +test('native ban page uses the scoped template-and-dialog admin flow', () => { + const banPage = read('src/Admin/Native/UsersMenusBan.php'); + const banQuery = read('src/Admin/Native/UsersMenusBanQuery.php'); + const banActions = read('src/Admin/Native/UsersMenusBanActions.php'); + const banDialogs = read('src/Admin/Native/UsersMenusBanDialogs.php'); + const banJs = read('assets/js/admin-user-ban.js'); + const banCss = read('assets/css/admin/user-ban.css'); - // 菜单与页面标题对齐参考主题(page title=处理举报及禁封申诉,menu title=举报&禁封申诉) - assert.match(usersMenus, /__\('处理举报及禁封申诉', 'aksrt'\),\s*__\('举报&禁封申诉', 'aksrt'\)/, 'submenu titles should match the reference theme') - assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme') + assert.match(usersMenus, /use UsersMenusBan;/); + assert.match(usersMenus, /admin_post_aksrt_users_update_report/); + assert.match(usersMenus, /admin_post_aksrt_users_process_appeal/); + assert.doesNotMatch(usersMenus, /use UsersMenusTickets;/, 'ban review must not use the deleted ticket renderer'); + assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php'))); - // 类型筛选按钮组 + 状态 subsubsub + 搜索 - for (const label of [`__('禁封申诉', 'aksrt')`, `__('用户举报', 'aksrt')`, `__('待处理', 'aksrt')`, `__('已批准', 'aksrt')`, `__('已拒绝', 'aksrt')`, `__('搜索记录', 'aksrt')`]) { - assert.ok(tickets.includes(label), `missing reference-theme label: ${label}`) + for (const key of ['type', 'status', 's', 'send_user', 'orderby', 'order']) { + assert.ok(banQuery.includes(`'${key}'`), `missing ban filter: ${key}`); } + assert.match(banQuery, /private const BAN_PER_PAGE = 20/); + assert.match(banQuery, /number'\s*=>\s*-1/); + assert.match(banQuery, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/); + assert.match(banQuery, /'counts'\s*=>\s*\$counts/); - // 表头列与参考主题一致:状态/类型/提交用户/详情/提交时间/更新时间(带排序) - for (const head of ['状态', '类型', '提交用户', '详情', '提交时间', '更新时间']) { - assert.ok(tickets.includes(`__('${head}', 'aksrt')`), `missing table head: ${head}`) + assert.match(banPage, /if \(\$hook !== 'users_page_aksrt_user_ban'\) return/); + assert.match(banPage, /wp_enqueue_style\('aksrt-admin-user-ban'.*NativeUi::STYLE_HANDLE/); + assert.match(banPage, /wp_enqueue_script\('aksrt-admin-user-ban'/); + assert.match(banPage, //); + assert.match(banDialogs, /'action'\s*=>\s*\$action/); + assert.match(banDialogs, /record_version/); + assert.match(banDialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/); + for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) { + assert.ok(banDialogs.includes(`'${name}'`), `review form must preserve ${name}`); } - assert.match(tickets, /orderby-but/) - assert.match(tickets, /dashicons-arrow-up/) + assert.match(banDialogs, /esc_url\(\$part, \['http', 'https'\]\)/); + assert.match(banDialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/); + assert.match(banDialogs, /target="_blank" rel="noopener noreferrer"/); + assert.match(banDialogs, /data-required-error/); - // 行渲染与处理表单对齐参考主题 - for (const label of [`__('立即处理', 'aksrt')`, `__('正在处理', 'aksrt')`, `__('被举报用户', 'aksrt')`, `__('被举报用户禁封信息', 'aksrt')`, `__('申诉用户', 'aksrt')`, `__('禁封信息', 'aksrt')`, `__('申诉信息', 'aksrt')`, `__('处理方式', 'aksrt')`, `__('处理留言', 'aksrt')`, `__('申诉通过(解封该用户)', 'aksrt')`, `__('申诉拒绝(保持原禁封状态)', 'aksrt')`, `__('此处内容会反馈给举报人', 'aksrt')`, `__('确认提交', 'aksrt')`, `__('用户状态正常', 'aksrt')`, `__('当前用户已解封', 'aksrt')`, `__('此用户已解封', 'aksrt')`, `__('暂无申请记录', 'aksrt')`]) { - assert.ok(tickets.includes(label), `missing process form label: ${label}`) + for (const method of ['handle_update_report', 'handle_process_appeal']) { + assert.match(banActions, new RegExp(`public function ${method}\\(\\): void`)); } - // 处理表单必须携带隐藏 action 字段:admin-post.php 靠它触发 - // admin_post_{action} 钩子,缺失时 WordPress 不输出任何内容(白屏)。 - // value 按工单类型分发(report → update_report,appeal → process_appeal)。 - assert.match(tickets, /name="action" value="' \. esc_attr\(\$kind === 'report' \? 'aksrt_users_update_report' : 'aksrt_users_process_appeal'\)/) - assert.match(usersMenus, /admin_post_aksrt_users_update_report/) - assert.match(usersMenus, /admin_post_aksrt_users_process_appeal/) - // 内联处理视图(参考主题方向):表单以合法的 colspan=6 单元格随待处理行 - // 预渲染;展开/取消走 JS 行↔表单互换(无整页刷新),信息只出现一次, - // 不再裸嵌 tbody 被 foster-parent 错位。 - assert.match(tickets, /';\s*\$this->render_ticket_process_form/) - assert.match(tickets, /data-aksrt-ticket-toggle="/) - assert.match(tickets, /data-aksrt-ticket-cancel="/) - assert.match(tickets, /data-aksrt-ticket-row="/) - assert.match(tickets, /e\.preventDefault\(\);/) - // 展开的处理表单提供“取消”按钮(确认提交右侧)。 - assert.match(tickets, /class="button button-primary process-submit"/) - assert.match(tickets, /' \. esc_html__\('取消', 'aksrt'\)/) - - // 分页对齐参考主题 zibpay_admin_pagenavi(每页 20 条) - assert.match(tickets, /TICKETS_PER_PAGE = 20/) - assert.match(tickets, /ticket_pagenavi\(int \$total_count, int \$per_page\)/) - for (const label of ['前往第一页', '前往上一页', '前往下一页', '前往最后一页', 'displaying-num', 'paging-input', 'total-pages']) { - assert.ok(tickets.includes(label), `missing pagenavi part: ${label}`) - } - - // 旧的三 tab 结构已收敛;举报处理表单不包含处罚下拉(对齐参考主题) - assert.ok(!usersMenus.includes('tab=reports'), 'old reports tab is gone') - assert.ok(!usersMenus.includes('tab=appeals'), 'old appeals tab is gone') - // 禁封限制 Tab 已移除(参考主题无此后台 Tab):禁封改走前台封禁弹窗。 - assert.ok(!usersMenus.includes('tab=restrictions'), 'restrictions tab is gone') - assert.ok(!usersMenus.includes('render_restrictions_tab'), 'restrictions tab renderer is gone') - assert.ok(!usersMenus.includes('handle_add_restriction'), 'backend ban form handler is gone') - assert.ok(!usersMenus.includes('handle_remove_restriction'), 'backend unban form handler is gone') - assert.ok(!usersMenus.includes('render_reports_tab')) - assert.ok(!usersMenus.includes('render_appeals_tab')) - assert.match(usersMenus, /use UsersMenusTickets;/) - assert.ok(!tickets.includes('name="action_taken"'), 'report process form should be status-only like the reference theme') - // 工单处理页「点击此处」跳转前台用户公开主页(封面三点菜单 → 封禁弹窗)。 - assert.match(tickets, /aksrt_get_user_home_url\(\$target_id\)/) - assert.ok(!tickets.includes('tab=restrictions'), 'ticket ban link must not point at the removed backend tab') -}) + assert.match(banActions, /check_admin_referer\('aksrt_users_update_report_'/); + assert.match(banActions, /check_admin_referer\('aksrt_users_process_appeal_'/); + assert.match(banActions, /hash_equals\(\$this->ban_record_version\(\$row\), \$submitted\)/); + assert.match(banActions, /\$this->ban_request_is_current\(\$row, 'pending'\)/); + assert.match(banActions, /\$new_status === 'dismissed' && \$note === ''/); + assert.match(banActions, /\$decision === 'reject' && \$note === ''/); + assert.match(banActions, /lift_user_restriction\(\$user_id\)/); + assert.match(banActions, /'paged'\s*=>/); + assert.match(banJs, /window\.addEventListener\('pageshow'/); + assert.match(banJs, /previousOpener\.focus\(\)/); + assert.match(banJs, /if \(!submitting\) dialog\.close\(\)/); + assert.match(banCss, /html\.aksrt-ban-modal-open \{ overflow: hidden/); +}); test('report modal mirrors the reference theme: preset reasons, image evidence, notice, geometry', () => { const scripts = read('src/Core/ScriptsPart1c.php') // 预置原因真实数据源是 ban_preset_reason(UserRuntime::ban_settings),