feat(admin): review reports and ban appeals in dialogs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 23:06:04 +08:00
1 parent 32ac2112b0
commit 59ddf677b6
13 files changed
+2122 -704

No files matched your search

-9
View File
@@ -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); }
+125
View File
@@ -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; }
}
+139
View File
@@ -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();
});
})();
+5 -96
View File
@@ -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;
+201
View File
@@ -0,0 +1,201 @@
<?php
/** Native report and ban appeal queue. @package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\User\Restriction;
defined('ABSPATH') || exit;
trait UsersMenusBan {
use UsersMenusBanQuery;
use UsersMenusBanDialogs;
use UsersMenusBanActions;
private array $ban_restrictions = [];
public function enqueue_ban_assets(string $hook): void {
if ($hook !== 'users_page_aksrt_user_ban') return;
$css = '/assets/css/admin/user-ban.css';
$js = '/assets/js/admin-user-ban.js';
wp_enqueue_style('aksrt-admin-user-ban', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-user-ban', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
public function render_ban_page(): void {
$this->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 '<div class="aksrt-native-table-wrap">';
$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 '<dialog id="aksrt-ban-dialog" class="aksrt-ban-dialog" aria-labelledby="aksrt-ban-dialog-title" aria-describedby="aksrt-ban-dialog-description"></dialog>';
echo '<noscript><p class="aksrt-native-callout">' . esc_html__('请启用 JavaScript 后查看详情和处理审核。', 'aksrt') . '</p></noscript>';
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 '<div class="aksrt-ban-toolbar"><div class="aksrt-ban-toolbar__top"><h2 class="aksrt-native-card__title">' . esc_html__('处理记录', 'aksrt') . '</h2>';
echo '<nav class="aksrt-ban-types" aria-label="' . esc_attr__('记录类型', 'aksrt') . '">';
foreach (['' => __('全部类型', 'aksrt'), 'report' => __('用户举报', 'aksrt'), 'appeal' => __('禁封申诉', 'aksrt')] as $key => $label) {
echo '<a href="' . esc_url($this->ban_page_url(array_merge($filters, ['type' => $key]))) . '"' . ($key === $filters['type'] ? ' aria-current="page"' : '') . '>' . esc_html($label) . '</a>';
}
echo '</nav></div><div class="aksrt-ban-toolbar__bottom"><ul class="subsubsub" aria-label="' . esc_attr__('处理状态筛选', 'aksrt') . '">';
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 '<li><a href="' . esc_url($this->ban_page_url(array_merge($filters, ['status' => $key]))) . '"' . ($key === $filters['status'] ? ' class="current" aria-current="page"' : '') . '>' . esc_html($label)
. ' <span class="count">' . esc_html(number_format_i18n($counts[$key === '' ? 'all' : $key])) . '</span></a></li>';
}
echo '</ul><form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_user_ban">';
foreach (['type', 'status', 'send_user', 'orderby', 'order'] as $key) {
echo '<input type="hidden" name="' . esc_attr($key) . '" value="' . esc_attr((string) $filters[$key]) . '">';
}
echo '<label class="screen-reader-text" for="aksrt-ban-search">' . esc_html__('搜索用户或记录内容', 'aksrt') . '</label>';
echo '<input type="search" id="aksrt-ban-search" name="s" value="' . esc_attr($filters['s']) . '" placeholder="' . esc_attr__('用户 / 目标 ID / 原因 / 申诉资料', 'aksrt') . '">';
echo '<button type="submit" class="button">' . esc_html__('搜索', 'aksrt') . '</button>';
if ($filters['s'] !== '' || $filters['send_user'] > 0) {
echo '<a class="button-link" href="' . esc_url($this->ban_page_url(array_merge($filters, ['s' => '', 'send_user' => 0]))) . '">' . esc_html__('清除筛选', 'aksrt') . '</a>';
}
echo '</form></div>';
if ($filters['send_user'] > 0) {
$user = get_userdata($filters['send_user']);
echo '<p class="aksrt-ban-filter-hint">' . esc_html(sprintf(__('当前仅显示 %s 提交的记录。', 'aksrt'), $user ? $user->display_name : '#' . $filters['send_user'])) . '</p>';
}
echo '</div>';
}
private function render_ban_table(array $items, array $filters, int $page): void {
echo '<table class="widefat fixed striped aksrt-ban-table"><caption class="screen-reader-text">' . esc_html__('举报与禁封申诉处理记录', 'aksrt') . '</caption><thead><tr>';
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 '<th scope="col" class="column-' . esc_attr($key) . '"' . ($active ? ' aria-sort="' . ($filters['order'] === 'ASC' ? 'ascending' : 'descending') . '"' : '') . '>';
if ($sortable) {
$order = $active && $filters['order'] === 'DESC' ? 'ASC' : 'DESC';
$title = sprintf($order === 'ASC' ? __('按%s升序排列', 'aksrt') : __('按%s降序排列', 'aksrt'), $label);
echo '<a class="aksrt-ban-sort" href="' . esc_url($this->ban_page_url(array_merge($filters, ['orderby' => $key, 'order' => $order]))) . '" aria-label="' . esc_attr($title) . '">' . esc_html($label) . '<span aria-hidden="true">' . ($active ? ($filters['order'] === 'ASC' ? '↑' : '↓') : '↕') . '</span></a>';
} else {
echo esc_html($label);
}
echo '</th>';
}
echo '</tr></thead><tbody>';
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 '</tbody></table>';
}
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 '<tr><td class="column-type">' . NativeUi::badge($report ? __('用户举报', 'aksrt') : __('禁封申诉', 'aksrt'), $report ? 'info' : 'neutral', false);
echo '<div class="aksrt-ban-sub">#' . (int) $row['id'] . '</div></td><td class="column-user">' . NativeUi::user_cell($user_id, __('用户已删除', 'aksrt'));
if ($user_id > 0) {
echo '<a class="aksrt-ban-user-filter" href="' . esc_url($this->ban_page_url(array_merge($filters, ['send_user' => $user_id]))) . '">' . esc_html__('筛选此用户', 'aksrt') . '</a>';
}
echo '</td><td class="column-summary">';
if ($report) {
echo '<strong>' . $this->ban_target_link((string) ($payload['target_type'] ?? ''), (int) ($payload['target_id'] ?? 0)) . '</strong>';
} else {
echo '<strong>' . esc_html__('申请解除禁封', 'aksrt') . '</strong>';
}
echo '<div class="aksrt-ban-summary">' . esc_html((string) ($payload['reason'] ?? '')) . '</div>';
$this->render_ban_detail_template($row, $filters, $page);
echo '</td><td class="column-status">' . $this->ban_status_badge($row) . '</td>';
echo '<td class="column-time">' . esc_html($this->ban_time((string) $row['time'])) . '</td>';
echo '<td class="column-updated">' . esc_html($this->ban_time((string) $row['updated'])) . '</td>';
echo '<td class="column-actions"><button type="button" class="button' . ($row['status'] === 'pending' ? ' button-primary' : '') . '" data-aksrt-ban-view="' . esc_attr($key) . '" aria-haspopup="dialog" aria-controls="aksrt-ban-dialog">'
. esc_html($row['status'] === 'pending' ? __('审核', 'aksrt') : __('查看详情', 'aksrt')) . '</button></td></tr>';
}
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 !== '' ? '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($label) . '</a>' : 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;
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusBanActions {
public function handle_update_report(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$report_id = absint($this->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);
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
/** Report and ban appeal detail/review dialog templates. @package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\User\Restriction;
defined('ABSPATH') || exit;
trait UsersMenusBanDialogs {
private function render_ban_detail_template(array $row, array $filters, int $page): void {
$pending = $row['status'] === 'pending';
$report = $row['kind'] === 'report';
$key = $row['kind'] . '-' . $row['id'];
echo '<template id="aksrt-ban-detail-' . esc_attr($key) . '">';
if ($pending) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ban-dialog__frame" data-aksrt-ban-review>';
$this->render_ban_context($row, $filters, $page);
} else {
echo '<div class="aksrt-ban-dialog__frame">';
}
$title = $report
? ($pending ? __('审核用户举报', 'aksrt') : __('用户举报详情', 'aksrt'))
: ($pending ? __('审核禁封申诉', 'aksrt') : __('禁封申诉详情', 'aksrt'));
echo '<div class="aksrt-ban-dialog__head"><div><h2 id="aksrt-ban-dialog-title" tabindex="-1" autofocus>' . esc_html($title) . '</h2>';
echo '<p id="aksrt-ban-dialog-description">' . esc_html($pending ? __('先核对原始资料,再选择处理结果;提交后将通知用户。', 'aksrt') : __('该记录已处理,以下资料和结果仅供查看。', 'aksrt')) . '</p></div>';
echo '<button type="button" class="aksrt-ban-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></div>';
echo '<div class="aksrt-ban-dialog__body"><div class="aksrt-ban-applicant">' . NativeUi::user_cell((int) $row['user_id'], __('用户已删除', 'aksrt')) . $this->ban_status_badge($row) . '</div>';
echo '<dl class="aksrt-ban-meta">';
$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 '</dl>';
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 '</div><div class="aksrt-ban-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>' . esc_html($pending ? __('取消', 'aksrt') : __('关闭', 'aksrt')) . '</button>';
if ($pending) {
echo '<button type="submit" class="button button-primary" data-aksrt-ban-submit disabled'
. ' data-idle-label="' . esc_attr__('请选择处理结果', 'aksrt') . '"'
. ' data-approve-label="' . esc_attr($report ? __('确认标记已处理', 'aksrt') : __('确认通过并解除限制', 'aksrt')) . '"'
. ' data-reject-label="' . esc_attr__('确认驳回', 'aksrt') . '"'
. ' data-busy-label="' . esc_attr__('正在提交…', 'aksrt') . '">' . esc_html__('请选择处理结果', 'aksrt') . '</button>';
}
echo '</div>' . ($pending ? '</form>' : '</div>') . '</template>';
}
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 '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '">';
}
wp_nonce_field($action . '_' . (int) $row['id']);
}
private function render_ban_meta_item(string $label, string $value): void {
echo '<div><dt>' . esc_html($label) . '</dt><dd>' . esc_html($value !== '' ? $value : '—') . '</dd></div>';
}
private function render_ban_text_section(string $title, string $text): void {
echo '<section class="aksrt-ban-section"><h3>' . esc_html($title) . '</h3><div class="aksrt-ban-copy">';
// 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
? '<a href="' . esc_url($part, ['http', 'https']) . '" target="_blank" rel="noopener noreferrer">' . esc_html($part) . '</a>'
: esc_html($part !== '' ? $part : '');
}
if ($text === '') echo esc_html__('未填写', 'aksrt');
echo '</div></section>';
}
private function render_ban_report_details(array $row): void {
$payload = $row['payload'];
$type = (string) ($payload['target_type'] ?? '');
$id = (int) ($payload['target_id'] ?? 0);
echo '<section class="aksrt-ban-section"><h3>' . esc_html__('举报对象', 'aksrt') . '</h3><div class="aksrt-ban-target">' . $this->ban_target_link($type, $id) . '</div></section>';
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 '<p class="aksrt-ban-profile-link"><a href="' . esc_url($this->ban_user_home($id)) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('查看用户主页并管理禁封(新窗口)', 'aksrt') . '</a></p>';
}
}
if ($row['status'] === 'pending') {
echo '<p class="aksrt-native-callout">' . esc_html__('本次审核仅记录举报处理结果,不会自动禁封用户或删除内容。请先核实举证并完成必要处置。', 'aksrt') . '</p>';
}
}
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 '<section class="aksrt-ban-section"><h3>' . esc_html__('申诉资料', 'aksrt') . '</h3><dl class="aksrt-ban-materials">';
foreach ($data as $label => $value) {
if (is_scalar($value)) $this->render_ban_meta_item((string) $label, (string) $value);
}
echo '</dl></section>';
}
$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 '<p class="aksrt-ban-profile-link"><a href="' . esc_url($this->ban_user_home((int) $row['user_id'])) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('查看申诉用户主页(新窗口)', 'aksrt') . '</a></p>';
}
}
private function render_ban_restriction_section(int $user_id): void {
echo '<section class="aksrt-ban-section"><h3>' . esc_html__('当前限制', 'aksrt') . '</h3>';
if (!class_exists(Restriction::class)) {
echo '<p class="aksrt-native-callout is-warning">' . esc_html__('限制服务不可用,暂时无法核对当前状态。', 'aksrt') . '</p></section>';
return;
}
$rows = $this->ban_restrictions[$user_id] ?? [];
if ($rows === []) {
echo '<p class="aksrt-ban-normal">' . esc_html__('当前无生效限制。此处仅展示当前状态,不会自动更改处理结果。', 'aksrt') . '</p></section>';
return;
}
$labels = [Restriction::TYPE_WARN => __('警告', 'aksrt'), Restriction::TYPE_MUTE => __('禁言', 'aksrt'), Restriction::TYPE_SUSPEND => __('停权', 'aksrt'), Restriction::TYPE_BAN => __('封禁', 'aksrt')];
echo '<ul class="aksrt-ban-restrictions">';
foreach ($rows as $restriction) {
echo '<li><div class="aksrt-ban-restriction__title">' . NativeUi::badge($labels[$restriction['type']] ?? (string) $restriction['type'], 'warning', false)
. '<span>' . esc_html(($restriction['scope'] ?? '') === Restriction::SCOPE_COMMENT ? __('评论范围', 'aksrt') : __('全站范围', 'aksrt')) . '</span></div>';
echo '<div class="aksrt-ban-copy">' . esc_html((string) ($restriction['reason'] ?? '')) . '</div>';
$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 '<div class="aksrt-ban-sub">' . esc_html(sprintf(__('开始:%1$s;结束:%2$s', 'aksrt'), $start, $end)) . '</div></li>';
}
echo '</ul></section>';
}
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 '<section class="aksrt-ban-section"><h3>' . esc_html__('处理记录', 'aksrt') . '</h3><ol class="aksrt-ban-history">';
foreach ($notes as $note) {
if (!is_array($note)) continue;
$operator = get_userdata((int) ($note['operator'] ?? 0));
echo '<li><div class="aksrt-ban-history__meta"><strong>' . esc_html($labels[$note['status'] ?? ''] ?? (string) ($note['status'] ?? '')) . '</strong><span>'
. esc_html($operator ? $operator->display_name : __('管理员', 'aksrt')) . '</span><time>' . esc_html((string) ($note['timestamp'] ?? '')) . '</time></div>';
echo '<div class="aksrt-ban-copy">' . esc_html((string) ($note['note'] ?? '') !== '' ? (string) $note['note'] : __('未填写处理留言', 'aksrt')) . '</div></li>';
}
echo '</ol></section>';
}
private function render_ban_review_fields(bool $report): void {
echo '<section class="aksrt-ban-review"><h3>' . esc_html__('审核处理', 'aksrt') . '</h3><fieldset class="aksrt-ban-decisions"><legend>' . esc_html__('处理结果(必选)', 'aksrt') . '</legend>';
$choices = $report ? ['resolved' => __('标记已处理', 'aksrt'), 'dismissed' => __('驳回举报', 'aksrt')] : ['approve' => __('通过申诉', 'aksrt'), 'reject' => __('驳回申诉', 'aksrt')];
foreach ($choices as $value => $label) {
echo '<label><input type="radio" name="' . ($report ? 'new_status' : 'decision') . '" value="' . esc_attr($value) . '" required><span>' . esc_html($label) . '</span></label>';
}
echo '</fieldset>';
if (!$report) {
echo '<p class="aksrt-native-callout is-warning">' . esc_html__('通过申诉将解除该用户当前生效的封禁、停权和禁言,保留警告记录;驳回不会改变限制。', 'aksrt') . '</p>';
}
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label" data-aksrt-ban-note-label data-optional="' . esc_attr__('处理说明(选填)', 'aksrt') . '" data-required="' . esc_attr__('驳回原因(必填)', 'aksrt') . '">' . esc_html__('处理说明(选填)', 'aksrt') . '</span>';
echo '<textarea name="note" rows="3" data-required-error="' . esc_attr__('请填写驳回原因,不能只包含空格', 'aksrt') . '" aria-describedby="aksrt-ban-note-hint"></textarea></label>';
echo '<p id="aksrt-ban-note-hint" class="aksrt-native-field__hint">' . esc_html($report ? __('处理结果及说明会反馈给举报人。驳回时请填写具体原因。', 'aksrt') : __('处理结果及说明会反馈给申诉用户。驳回时请说明原因,便于用户补充资料。', 'aksrt')) . '</p></section>';
}
}
+387
View File
@@ -0,0 +1,387 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusBanQuery {
private const BAN_PER_PAGE = 20;
private function get_ban_filters(array $input): array {
$type = sanitize_key($this->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<int,array<string,mixed>>,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<int,array<string,mixed>> */
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<int,array<string,mixed>> */
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<int,array<string,mixed>> $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']);
});
}
}
-538
View File
@@ -1,538 +0,0 @@
<?php
/**
* 举报&禁封申诉工单列表 — 对齐参考主题(Zibll)的后台管理页:
* 类型筛选按钮组 + 状态 subsubsub + 搜索 + 排序表头 + 行内处理表单 + 分页。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTickets {
private const TICKETS_PER_PAGE = 20;
/**
* 统一行结构:kind=report|appeal, id, user_id, status(pending|resolved|dismissed),
* raw_status, time, updated, payload。
*/
private function collect_ticket_rows(string $type, string $status, string $s, int $send_user, string $orderby, string $order): array {
$rows = [];
if ($type !== 'appeal' && class_exists('\AKSRT\Messages\ReportCenter')) {
$reports = \AKSRT\Messages\ReportCenter::get_instance()->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 '<div class="aksrt-native-toolbar">';
// 类型筛选按钮组(对齐参考主题:全部 / 禁封申诉 / 用户举报)
echo '<div class="aksrt-native-inline">';
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('<a class="button%s" href="%s">%s</a> ', $type === $t ? ' button-primary' : '', esc_url($url), esc_html($label));
}
echo '</div>';
// 搜索(对齐参考主题:右侧搜索记录表单)
echo '<form class="aksrt-native-filters" method="get">';
echo '<input type="hidden" name="page" value="aksrt_user_ban" /><input type="hidden" name="tab" value="tickets" />';
if ($type !== '') {
echo '<input type="hidden" name="type" value="' . esc_attr($type) . '" />';
}
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('搜索记录', 'aksrt') . '" />';
submit_button(__('提交', 'aksrt'), 'primary', '', false);
echo '</form></div>';
// 状态 subsubsub(对齐参考主题:全部 | 待处理 | 已批准 | 已拒绝)
echo '<ul class="subsubsub">';
$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('<li><a%s href="%s">%s</a>%s</li> ', $current, esc_url($url), esc_html($label), esc_html($sep));
}
echo '</ul>';
if ($s !== '') {
echo '<p class="aksrt-native-muted">"' . esc_html($s) . '" ' . esc_html__('的搜索结果', 'aksrt') . '</p>';
}
NativeUi::card_close();
$process_id = isset($_GET['id']) ? (string) wp_unslash($_GET['id']) : '';
// 处理视图不设“查看全部”链接(参考主题无此元素):重置过滤由顶部
// 类型按钮组与状态 subsubsub 承担,工单行内直接展开处理表单。
NativeUi::table_card_open();
echo '<table class="widefat fixed striped posts">';
echo '<thead><tr>';
// 表头(对齐参考主题:状态/类型/提交用户/详情/提交时间/更新时间 + 排序箭头)
$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 = '<span class="orderby-but">';
$orderby_html .= '<a title="' . esc_attr__('降序', 'aksrt') . '" href="' . esc_url(add_query_arg('order', 'ASC', $orderby_url)) . '"><span class="dashicons dashicons-arrow-up"></span></a>';
$orderby_html .= '<a title="' . esc_attr__('升序', 'aksrt') . '" href="' . esc_url(add_query_arg('order', 'DESC', $orderby_url)) . '"><span class="dashicons dashicons-arrow-down"></span></a>';
$orderby_html .= '</span>';
}
echo '<th width="' . esc_attr($thead['width']) . '">' . esc_html($thead['name']) . $orderby_html . '</th>';
}
echo '</tr></thead><tbody>';
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 '<tr data-aksrt-ticket-process="' . esc_attr($process_key) . '"' . $hidden_attr . '><td colspan="6">';
$this->render_ticket_process_form($row, $action_url);
echo '</td></tr>';
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
$this->ticket_pagenavi($total, self::TICKETS_PER_PAGE);
NativeUi::card_close();
// 行内展开/收起的轻量交互(管理员环境必有 jQuery)。
echo '<script>
(function ($) {
function processRow(key) {
return $("[data-aksrt-ticket-process=\\"" + key + "\\"]");
}
function sourceRow(key) {
return $("[data-aksrt-ticket-row=\\"" + key + "\\"]");
}
$(document).on("click", "[data-aksrt-ticket-toggle]", function (e) {
var key = $(this).attr("data-aksrt-ticket-toggle");
var $row = processRow(key);
if (!$row.length) return;
e.preventDefault();
var show = !$row.is(":visible");
// 一次只展开一个:先收起其它展开的表单并换回其列表行。
$("[data-aksrt-ticket-process]:visible").not($row).each(function () {
$(this).hide();
sourceRow($(this).attr("data-aksrt-ticket-process")).show();
});
if (show) {
sourceRow(key).hide();
$row.show();
} else {
$row.hide();
sourceRow(key).show();
}
});
$(document).on("click", "[data-aksrt-ticket-cancel]", function (e) {
var key = $(this).attr("data-aksrt-ticket-cancel");
e.preventDefault();
processRow(key).hide();
sourceRow(key).show();
});
})(jQuery);
</script>';
}
/** 分页(对齐参考主题 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 '<div class="tablenav bottom"><div class="tablenav-pages">';
echo '<span class="displaying-num">' . esc_html(sprintf(__('每页 %1$s 共%2$s', 'aksrt'), $per_page, $total_count)) . '</span>';
echo '<span class="pagination-links">';
echo '<a class="first-page button' . ($current_page <= 1 ? ' disabled' : '') . '" title="' . esc_attr__('前往第一页', 'aksrt') . '" href="' . esc_url($first_page_url) . '">«</a>';
echo '<a class="prev-page button' . ($current_page <= 1 ? ' disabled' : '') . '" title="' . esc_attr__('前往上一页', 'aksrt') . '" href="' . esc_url($prev_page_url) . '">‹</a>';
echo '<span class="paging-input">' . esc_html(sprintf(__('第 %1$s 页,共 ', 'aksrt'), $current_page)) . '<span class="total-pages">' . esc_html((string) $total_pages) . '</span>' . esc_html__(' 页', 'aksrt') . '</span>';
echo '<a class="next-page button' . ($current_page >= $total_pages ? ' disabled' : '') . '" title="' . esc_attr__('前往下一页', 'aksrt') . '" href="' . esc_url($next_page_url) . '">›</a>';
echo '<a class="last-page button' . ($current_page >= $total_pages ? ' disabled' : '') . '" title="' . esc_attr__('前往最后一页', 'aksrt') . '" href="' . esc_url($last_page_url) . '">»</a>';
echo '</span></div><br class="clear"></div>';
}
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 = '<a class="button" data-aksrt-ticket-toggle="' . esc_attr($process_key) . '" href="' . esc_url($url) . '">' . esc_html__('立即处理', 'aksrt') . '</a>';
} 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 = '<a href="' . esc_url(add_query_arg('send_user', $user_id, $page_url)) . '">' . esc_html($user->display_name) . '</a>';
$user_html .= '<div class="row-actions">';
$user_html .= '<a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html__('管理', 'aksrt') . '</a> | ';
$home = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user_id) : get_author_posts_url($user_id);
$user_html .= '<a href="' . esc_url($home) . '">' . esc_html__('查看', 'aksrt') . '</a>';
$user_html .= '</div>';
} 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 '<tr' . ($hidden ? ' style="display:none;"' : '') . ' data-aksrt-ticket-row="' . esc_attr($process_key) . '">';
echo '<td>' . $status_html . '</td>';
echo '<td>' . $type_html . '</td>';
echo '<td>' . $user_html . '</td>';
echo '<td><div class="aksrt-native-clamp">' . $desc_html . '</div></td>';
echo '<td>' . esc_html((string) $row['time']) . '</td>';
echo '<td>' . esc_html((string) ($row['updated'] !== '' ? $row['updated'] : '—')) . '</td>';
echo '</tr>';
}
/** 举报行详情(对齐参考主题:举报用户/举报内容红字 + 原因) */
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 = '<div class="aksrt-native-text--danger">' . esc_html__('举报用户:', 'aksrt') . ($link !== '' ? $link : esc_html('#' . $target_id)) . '</div>';
} else {
$html = '<div class="aksrt-native-text--danger">' . esc_html__('举报内容:', 'aksrt') . ($link !== '' ? $link : esc_html($target_type . ' #' . $target_id)) . '</div>';
}
$html .= '<div>' . esc_html(sprintf(__('举报原因:%s', 'aksrt'), (string) ($r['reason'] ?? ''))) . '</div>';
if (!empty($r['category'])) {
$html .= '<div>' . esc_html(sprintf(__('分类:%s', 'aksrt'), (string) $r['category'])) . '</div>';
}
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 .= '<div>' . esc_html(sprintf(__('申诉说明:%s', 'aksrt'), (string) $payload['reason'])) . '</div>';
}
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 = '<div class="aksrt-native-text--warning">' . esc_html(sprintf(__('禁封状态:%s', 'aksrt'), __('封禁中(禁止登录)', 'aksrt'))) . '</div>';
$html .= '<div>' . esc_html(sprintf(__('禁封原因:%s', 'aksrt'), (string) ($ban['reason'] ?? ''))) . '</div>';
$html .= '<div>' . esc_html(sprintf(__('开始时间:%s', 'aksrt'), (string) ($ban['created_at'] ?? ''))) . '</div>';
$html .= '<div>' . esc_html(sprintf(__('结束时间:%s', 'aksrt'), $end)) . '</div>';
return $html;
}
if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) {
return '<div class="aksrt-native-text--warning">' . esc_html(sprintf(__('限制状态:%s', 'aksrt'), __('停权中', 'aksrt'))) . '</div>';
}
if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) {
return '<div class="aksrt-native-text--warning">' . esc_html(sprintf(__('限制状态:%s', 'aksrt'), __('禁言中', 'aksrt'))) . '</div>';
}
// 对齐参考主题:申诉列表行中用户已无禁封 → 显示已解封并自动结案
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 '<div class="aksrt-native-text--warning">' . esc_html__('此用户已解封', 'aksrt') . '</div>';
}
// 对齐参考主题:申诉处理表单中用户已解封的提示
if ($context === 'appeal_form') {
return '<div class="aksrt-native-text--success">' . esc_html__('当前用户已解封', 'aksrt') . '</div>';
}
return '<div class="aksrt-native-text--success">' . esc_html__('用户状态正常', 'aksrt') . '</div>';
}
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 '<a target="_blank" href="' . esc_url((string) get_permalink($post)) . '">' . esc_html(get_the_title($post)) . '</a>';
}
} elseif ($target_type === 'comment') {
$comment = get_comment($target_id);
if ($comment instanceof \WP_Comment) {
return '<a target="_blank" href="' . esc_url((string) get_comment_link($comment)) . '">#' . $target_id . '</a>';
}
} 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 '<a target="_blank" href="' . esc_url($url) . '">' . esc_html($user->display_name) . '</a>';
}
}
return '';
}
/** 行内处理表单(对齐参考主题 form-table 结构) */
private function render_ticket_process_form(array $row, string $action_url): void {
$kind = $row['kind'];
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
// admin-post.php 依赖 $_REQUEST['action'] 触发 admin_post_{action} 钩子;
// 缺失时 WordPress 不输出任何内容(白屏),这里按工单类型分发。
echo '<input type="hidden" name="action" value="' . esc_attr($kind === 'report' ? 'aksrt_users_update_report' : 'aksrt_users_process_appeal') . '" />';
echo '<table class="form-table"><tbody>';
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 '<tr><th>' . esc_html__('被举报用户', 'aksrt') . '</th><td>';
echo $link !== '' ? $link : esc_html($target_type . ' #' . $target_id);
echo '<input type="hidden" name="report_id" value="' . esc_attr((string) $row['id']) . '" />';
echo '<input type="hidden" name="new_status" value="resolved" />';
echo '</td></tr>';
echo '<tr><th>' . esc_html__('举报信息', 'aksrt') . '</th><td>';
echo esc_html(sprintf(__('举报原因:%s', 'aksrt'), (string) ($r['reason'] ?? ''))) . '<br>';
if (!empty($r['category'])) {
echo esc_html(sprintf(__('分类:%s', 'aksrt'), (string) $r['category'])) . '<br>';
}
echo esc_html(sprintf(__('提交时间:%s', 'aksrt'), (string) $row['time']));
echo '</td></tr>';
if ($target_type === 'user') {
echo '<tr><th>' . esc_html__('被举报用户禁封信息', 'aksrt') . '</th><td>';
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 '<div class="aksrt-native-text--success">' . esc_html__('用户状态正常', 'aksrt') . '</div>';
echo '<p class="description">' . esc_html__('请查阅相关资料以及举证,如举报事实清楚,可以', 'aksrt');
echo ' <a target="_blank" href="' . esc_url($ban_url) . '">' . esc_html__('点击此处', 'aksrt') . '</a> ';
echo esc_html__('对用户做禁封处理,并将处理结果填写至下方', 'aksrt') . '</p>';
}
echo '</td></tr>';
}
echo '<tr><th>' . esc_html__('处理说明', 'aksrt') . '</th><td>';
echo '<textarea rows="2" cols="40" name="note" placeholder="' . esc_attr__('请输入本次处理的方式及说明', 'aksrt') . '"></textarea>';
echo '<p class="description">' . esc_html__('此处内容会反馈给举报人', 'aksrt') . '</p>';
echo '</td></tr>';
wp_nonce_field('aksrt_users_update_report_' . (int) $row['id']);
} else {
$uid = (int) $row['id'];
$user = get_userdata($uid);
echo '<tr><th>' . esc_html__('申诉用户', 'aksrt') . '</th><td>';
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 '<a target="_blank" href="' . esc_url($url) . '">' . esc_html($user->display_name) . '</a>';
} else {
echo esc_html('#' . $uid);
}
echo '<input type="hidden" name="user_id" value="' . esc_attr((string) $uid) . '" />';
echo '</td></tr>';
echo '<tr><th>' . esc_html__('禁封信息', 'aksrt') . '</th><td>' . $this->ticket_ban_info_html($uid, 'appeal_form') . '</td></tr>';
echo '<tr><th>' . esc_html__('申诉信息', 'aksrt') . '</th><td>';
$payload = $row['payload'];
echo esc_html(sprintf(__('申诉类型:%s', 'aksrt'), (string) ($payload['reason_key'] ?? '—'))) . '<br>';
echo esc_html(sprintf(__('申诉说明:%s', 'aksrt'), (string) ($payload['reason'] ?? ''))) . '<br>';
echo esc_html(sprintf(__('提交时间:%s', 'aksrt'), (string) ($payload['time'] ?? '')));
echo '</td></tr>';
echo '<tr><th>' . esc_html__('处理方式', 'aksrt') . '</th><td>';
echo '<p><input type="radio" name="decision" id="aksrt-process-1-' . $uid . '" value="approve" checked="checked"><label for="aksrt-process-1-' . $uid . '" class="aksrt-native-text--info">' . esc_html__('申诉通过(解封该用户)', 'aksrt') . '</label></p>';
echo '<p><input type="radio" name="decision" id="aksrt-process-2-' . $uid . '" value="reject"><label for="aksrt-process-2-' . $uid . '" class="aksrt-native-text--danger">' . esc_html__('申诉拒绝(保持原禁封状态)', 'aksrt') . '</label></p>';
echo '</td></tr>';
echo '<tr><th>' . esc_html__('处理留言', 'aksrt') . '</th><td>';
echo '<textarea rows="2" cols="40" name="note" placeholder="' . esc_attr__('给用户留言', 'aksrt') . '"></textarea>';
echo '<p class="description">' . esc_html__('如需给用户留言请填写此处,如果拒绝请填写拒绝原因', 'aksrt') . '</p>';
echo '</td></tr>';
wp_nonce_field('aksrt_users_process_appeal_' . $uid);
}
echo '<tr><th></th><td><p>';
echo '<button type="submit" class="button button-primary process-submit">' . esc_html__('确认提交', 'aksrt') . '</button> ';
// 取消:收起表单、换回列表行(JS 切换;无 JS 时整页回列表兜底)。
echo '<a class="button" data-aksrt-ticket-cancel="' . esc_attr($row['kind'] . '-' . (string) $row['id']) . '" href="' . esc_url(remove_query_arg(['action', 'id'])) . '">' . esc_html__('取消', 'aksrt') . '</a>';
echo '</p></td></tr>';
echo '</tbody></table></form>';
}
}
@@ -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(`<input[^>]*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(/<dialog id="aksrt-ban-dialog"/g) || []).length, 1);
assert.equal((html.match(/<template id="aksrt-ban-detail-/g) || []).length, 20);
assert.match(html, /<template id="aksrt-ban-detail-report-1">\s*<form method="post"/);
assert.match(html, /<template id="aksrt-ban-detail-appeal-501">\s*<form method="post"/);
const resolved = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.notEqual(resolved, '');
assert.doesNotMatch(resolved, /<form|data-aksrt-ban-submit/);
assert.match(resolved, /该记录已处理/);
for (const [name, value] of Object.entries({
action: 'aksrt_users_update_report', report_id: '9001', filter_type: 'report', filter_status: 'pending',
s: 'needle', send_user: '7', orderby: 'updated', order: 'ASC', paged: '1',
})) {
assert.equal(inputValue(filteredHtml, name), value, `review form should carry ${name}`);
}
assert.match(filteredHtml, /name="_wpnonce" value="aksrt_users_update_report_9001-nonce"/);
assert.match(html, /name="user_id" value="501"/);
assert.match(html, /name="_wpnonce" value="aksrt_users_process_appeal_501-nonce"/);
assert.match(html, /<input type="radio"[^>]*required>/);
assert.doesNotMatch(html, /<input type="radio"[^>]*checked/);
});
test('PHP templates escape all appeal data and report evidence while preserving safe links', () => {
const { html } = phpFixture();
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /Name &lt;img src=x onerror=alert\(4\)&gt;/);
assert.match(html, /Applicant &lt;b&gt;value&lt;\/b&gt;/);
assert.match(html, /reason &lt;svg onload=alert\(1\)&gt;/);
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, /<dd>https:\/\/appeal\.example\.test\/document\.pdf<\/dd>/);
assert.doesNotMatch(html, /href="javascript:/i);
assert.doesNotMatch(html, /<img src=x onerror=/i);
assert.doesNotMatch(html, /<script>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, /<dialog id="aksrt-ban-dialog"/);
assert.match(dialogs, /<template id="aksrt-ban-detail-/);
assert.doesNotMatch(page, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(page, /data-aksrt-ticket-toggle|data-aksrt-ticket-cancel|data-aksrt-ban-toggle/);
assert.match(query, /BAN_PER_PAGE = 20/);
assert.match(query, /number'\s*=>\s*-1/);
assert.match(query, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/);
assert.match(query, /'counts'\s*=>\s*\$counts/);
assert.match(query, /raw_status/);
assert.match(dialogs, /'action'\s*=>\s*\$action/);
assert.match(dialogs, /record_version/);
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/);
for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) {
assert.ok(dialogs.includes(`'${name}'`), `missing hidden filter field ${name}`);
}
assert.match(dialogs, /esc_url\(\$part, \['http', 'https'\]\)/);
assert.match(dialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/);
assert.match(dialogs, /target="_blank" rel="noopener noreferrer"/);
assert.match(actions, /check_admin_referer\('aksrt_users_update_report_'/);
assert.match(actions, /check_admin_referer\('aksrt_users_process_appeal_'/);
assert.match(actions, /hash_equals\(\$this->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/);
});
+383
View File
@@ -0,0 +1,383 @@
<?php
/**
* Exercise the native ban queue, detail templates, and post handlers with WordPress fixtures.
*
* @package AKSRT
*/
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\User {
final class Restriction {
public const TYPE_WARN = 'warn';
public const TYPE_MUTE = 'mute';
public const TYPE_SUSPEND = 'suspend';
public const TYPE_BAN = 'ban';
public const SCOPE_COMMENT = 'comment';
public static int $lifted = 0;
public static function get_instance(): self { return new self(); }
public function lift_user_restriction(int $user_id): int { self::$lifted++; return $user_id > 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 '<nav data-test-pagination="' . esc_attr((string) $total) . ':' . esc_attr((string) $page) . '"></nav>';
}
}
}
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 '<input type="hidden" name="_wpnonce" value="' . esc_attr($action . '-nonce') . '">'; }
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 <script>alert(1)</script>' : '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 ? '<img src=x onerror=alert(1)>' : '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 <script>alert(1)</script> 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 <svg onload=alert(1)>',
'reason' => 'Appeal note <script>alert(2)</script> https://appeal.example.test/file.pdf javascript:alert(3)',
'data' => [
'Name <img src=x onerror=alert(4)>' => 'Applicant <b>value</b>',
'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);
}
@@ -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);
});
@@ -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, /<dialog id="aksrt-ban-dialog"/);
assert.match(banDialogs, /<template id="aksrt-ban-detail-/);
assert.match(banPage, /data-aksrt-ban-view=/);
assert.doesNotMatch(banPage, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(banPage, /data-aksrt-ticket-toggle|data-aksrt-ticket-cancel|data-aksrt-ban-toggle/);
assert.match(banDialogs, /if \(\$pending\) \{[\s\S]*?<form method="post"/);
assert.match(banDialogs, /else \{[\s\S]*?<div class="aksrt-ban-dialog__frame">/);
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, /<td colspan="6">';\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, /<a class="button" data-aksrt-ticket-cancel="' \. esc_attr\(\$row\['kind'\] \. '-' \. \(string\) \$row\['id'\]\) . '" href="' \. esc_url\(remove_query_arg\(\['action', 'id'\]\)\) . '">' \. 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),