Files
wordpress-theme-aksrt/inc/helpers/user-ban.php
T
kate522 7597972eea feat(user-ban): frontend ban modal replaces the backend restrictions tab
1:1 replicate the reference theme user-ban flow on the frontend:

- Ban/unban modal rendered by inc/helpers/user-ban.php, opened via
  AksrtModalRuntime.openRemote (600px desktop dialog, 360px initial
  height, mobile bottom drawer, c-yellow colorful unban header).
- Three entry points wired: user homepage cover three-dot menu,
  comment section menu, article author card menu (ban item takes
  priority over report, matching the reference hook order).
- REST endpoints /user-ban/modal and /user-ban/set under aksrt/v1,
  guarded by check_permission, persisted through User\Restriction.
- Submit semantics match zib_ajax_set_user_ban: ban reloads the page,
  unban only closes the modal.

Cleanup (no dual-track): the backend ban-restrictions tab, its form
handlers and flash keys are removed; ticket processing links to the
frontend user homepage as the reference theme does; admin/report and
appeal notification URLs move to type/status query args.

Verification: 337/337 widget-system contract tests, 41/41 admin and
front-submit tests, php -l clean on all changed PHP files.
2026-09-20 18:42:53 +08:00

286 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 前台用户禁封(小黑屋 / 封号)— 1:1 复刻参考主题 Zibll 前台禁封能力:
* zib_get_edit_user_ban_link → aksrt_get_edit_user_ban_link(三点菜单入口)
* zib_get_set_user_ban_modal → aksrt_get_user_ban_modal(禁封 / 解封弹窗)
*
* 数据源唯一:\AKSRT\User\Restriction(wp_aksrt_user_restriction 表)。
* 弹窗内容经 REST /user-ban/modal 输出(AksrtModalRuntime.openRemote 拉取,
* 与参考主题 refresh_modal 每次刷新同语义),提交走 REST /user-ban/set。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
/**
* 禁封功能开关(后台“用户&互动 → 禁封/举报 → 禁封功能”,
* 对齐参考主题 _pz('user_ban_s', true):缺键默认开启)。
*/
function aksrt_user_ban_enabled(): bool
{
$settings = class_exists('\AKSRT\Admin\SchemaProvider')
? \AKSRT\Settings\Settings::read_domain('users', 'report')
: (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []);
$settings = is_array($settings) ? $settings : [];
return !isset($settings['user_ban_s']) || !empty($settings['user_ban_s']);
}
/**
* 当前用户能否禁封目标用户(对齐参考主题 zib_current_user_can('set_user_ban') 语义)。
*/
function aksrt_current_user_can_ban(int $user_id): bool
{
if ($user_id <= 0 || !aksrt_user_ban_enabled()) {
return false;
}
$current_id = get_current_user_id();
if ($current_id <= 0 || $current_id === $user_id) {
return false;
}
if (!current_user_can('manage_options')) {
return false;
}
if (function_exists('is_super_admin') && is_super_admin($user_id)) {
return false;
}
return true;
}
/**
* 取用户当前生效中的禁封记录(ban 优先于 suspend,对齐后台单一状态语义)。
*/
function aksrt_get_user_current_ban(int $user_id): ?array
{
if ($user_id <= 0 || !class_exists('\AKSRT\User\Restriction')) {
return null;
}
$svc = \AKSRT\User\Restriction::get_instance();
return $svc->get_active_ban($user_id) ?: $svc->get_active_suspend($user_id);
}
/**
* 禁封 / 解封触发器(参考主题 zib_get_edit_user_ban_link)。
* 输出 data-aksrt-ban 委托入口,由 assets/js/user-ban.js 打开禁封弹窗。
*/
function aksrt_get_edit_user_ban_link(int $user_id = 0, string $class = '', string $con = ''): string
{
if (!aksrt_current_user_can_ban($user_id)) {
return '';
}
if ($con === '') {
$con = aksrt_get_user_current_ban($user_id)
? '<i class="fa fas fa-ban mr6" aria-hidden="true"></i>' . __('解封此用户', 'aksrt')
: '<i class="fa fas fa-ban mr6" aria-hidden="true"></i>' . __('禁封此用户', 'aksrt');
}
return '<a href="javascript:;" class="' . esc_attr($class) . '" data-aksrt-ban="' . esc_attr((string) $user_id) . '">' . $con . '</a>';
}
/**
* 用户主页封面三点菜单项(参考主题 author_header_drop_lists 钩子语义):
* 有禁封权限 → 禁封入口;否则(开启举报且非本人)→ 举报入口。
*/
function aksrt_get_user_cover_menu_action_html(int $user_id): string
{
if (aksrt_current_user_can_ban($user_id)) {
$ban = aksrt_get_user_current_ban($user_id);
$label = $ban ? __('解封此用户', 'aksrt') : __('禁封此用户', 'aksrt');
return '<button type="button" role="menuitem" data-aksrt-ban="' . esc_attr((string) $user_id) . '"><i class="fa fas fa-ban" aria-hidden="true"></i>' . esc_html($label) . '</button>';
}
return '';
}
/**
* 评论三点菜单项(参考主题 comments_action_lists 钩子语义):
* 管理员 → 禁封用户(固定文案,与参考主题 zib_ban_comments_action_lists_filter
* 一致——解封态由弹窗内容呈现);否则 → 举报( Aksrt 既有评论举报契约)。
*/
function aksrt_get_comment_ban_or_report_item(\WP_Comment $comment): string
{
$user_id = (int) $comment->user_id;
if ($user_id > 0 && aksrt_current_user_can_ban($user_id)) {
return '<li><a class="comment-ban-link" href="javascript:;" data-aksrt-ban="' . esc_attr((string) $user_id) . '"><i class="fa fas fa-ban mr6 fa-fw c-red" aria-hidden="true"></i>' . esc_html__('禁封用户', 'aksrt') . '</a></li>';
}
if (function_exists('aksrt_get_comment_report_html')) {
return aksrt_get_comment_report_html($comment);
}
return '';
}
/**
* 文章作者资料卡三点菜单项(参考主题 zib_get_post_more_dropdown 语义,菜单首项):
* 管理员 → 禁封用户(固定文案,解封态由弹窗内容呈现);否则 → 空(由既有
* 文章举报项兜底,与参考主题「禁封优先、否则举报」互斥语义一致)。
*/
function aksrt_get_post_author_ban_item(int $author_id, string $post_url = ''): string
{
if ($author_id <= 0 || get_current_user_id() === $author_id) {
return '';
}
if (aksrt_current_user_can_ban($author_id)) {
return '<a href="javascript:;" class="post-drop-meta__item" data-aksrt-ban="' . esc_attr((string) $author_id) . '"><i class="fa fas fa-ban" aria-hidden="true"></i><span>' . esc_html__('禁封用户', 'aksrt') . '</span></a>';
}
return '';
}
/**
* 弹窗关闭图标(与 report.js 同款 SVG 雪碧图契约)。
*/
function aksrt_user_ban_close_svg(): string
{
return '<svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg>';
}
/**
* 禁封 / 解封弹窗内容 — 1:1 复刻参考主题 zib_get_set_user_ban_modal:
* 未禁封 → 禁封表单(禁封方式 / 禁封时间 / 预置原因徽章 / 其它原因 /
* 禁封说明 / 删除全部内容 / 禁止用户申诉(含说明));
* 已禁封 → 解封视图(状态信息 + 解封留言表单)。
*
* @return string 弹窗 HTML(空串 = 无权操作或用户不存在)
*/
function aksrt_get_user_ban_modal(int $user_id): string
{
$user = $user_id > 0 ? get_userdata($user_id) : null;
if (!$user || !aksrt_current_user_can_ban($user_id)) {
return '';
}
$ban = aksrt_get_user_current_ban($user_id);
// 用户行(头像 + 昵称),与参考主题弹窗头部同构。
$avatar_url = get_avatar_url($user_id, ['size' => 40]);
$user_name = function_exists('aksrt_get_user_name')
? aksrt_get_user_name(['id' => $user_id, 'vip' => false, 'medal' => false])
: esc_html($user->display_name);
$user_row = '<div class="flex ac mb6">'
. '<img class="avatar-mini mr6" src="' . esc_url((string) $avatar_url) . '" alt="">'
. '<b>' . $user_name . '</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- aksrt_get_user_name escapes internally.
$html = '<div class="aksrt-user-ban-modal">';
if ($ban) {
// ── 已禁封视图:解封(参考主题解封弹窗同构) ──
$is_ban = (string) ($ban['type'] ?? '') === 'ban';
$unban_title = $is_ban ? __('解封用户', 'aksrt') : __('移出小黑屋', 'aksrt');
$status_text = $is_ban ? __('禁封中(禁止登录)', 'aksrt') : __('小黑屋', 'aksrt');
$end_time = !empty($ban['expires_at']) ? (string) $ban['expires_at'] : __('永久', 'aksrt');
// AKSRT 单一 reason 列:禁封说明以“禁封说明:”后缀并入(set_user_ban 同语义),
// 解封视图拆回参考主题的「原因 / 说明」两行。
$raw_reason = (string) ($ban['reason'] ?? '');
$reason = $raw_reason;
$ban_desc = '';
$desc_marker = __('禁封说明:', 'aksrt');
$marker_pos = strpos($raw_reason, "\n" . $desc_marker);
if (false !== $marker_pos) {
$reason = substr($raw_reason, 0, $marker_pos);
$ban_desc = substr($raw_reason, $marker_pos + strlen("\n" . $desc_marker));
}
// 用户名徽章(参考主题 zib_get_user_name(...&class=inflex ac badg p2-10 em09))。
$user_badge = function_exists('aksrt_get_user_name')
? aksrt_get_user_name(['id' => $user_id, 'class' => 'inflex ac badg p2-10 em09', 'vip' => false, 'medal' => false, 'auth' => false, 'level' => false])
: '<b>' . esc_html($user->display_name) . '</b>';
$set_row = static function (string $label, string $value): string {
return '<div class="mb10 flex"><div class="author-set-left" style="min-width: 80px;">' . esc_html($label) . '</div><div class="author-set-right mt6">' . $value . '</div></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- callers pass escaped HTML.
};
// 弹窗头(参考主题 zib_get_modal_colorful_header('c-yellow', ban 图标, 标题):
// 120px 多彩横幅 + 大图标 + 居中标题,与禁封视图的 edit-header 头对应)。
$html .= '<div class="modal-colorful-header colorful-bg c-yellow"><button type="button" class="close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">' . aksrt_user_ban_close_svg() . '</button><div class="colorful-make"></div><div class="text-center"><div class="em2x"><i class="fa fas fa-ban" aria-hidden="true"></i></div><div class="mt10 em12 padding-w10">' . esc_html($unban_title) . '</div></div></div>';
$html .= '<form class="aksrt-user-ban-form" data-aksrt-ban-form data-aksrt-ban-mode="unban" data-aksrt-ban-user="' . esc_attr((string) $user_id) . '">';
$html .= '<div class="mb20">';
$html .= $set_row(__('用户', 'aksrt'), $user_badge); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$html .= $set_row(__('状态', 'aksrt'), esc_html($status_text));
$html .= $set_row(__('开始时间', 'aksrt'), esc_html((string) ($ban['created_at'] ?? '')));
$html .= $set_row(__('结束时间', 'aksrt'), esc_html($end_time));
$html .= $set_row(__('原因', 'aksrt'), nl2br(esc_html($reason)));
$html .= $set_row(__('说明', 'aksrt'), nl2br(esc_html($ban_desc)));
$html .= '</div>';
$html .= '<div class="mb20"><textarea class="form-control" name="desc" tabindex="2" placeholder="' . esc_attr__('给用户的留言', 'aksrt') . '" rows="2" autoHeight="true" maxHeight="110"></textarea></div>';
$html .= '<div class="mt20 but-average modal-buts">';
$html .= '<button type="button" class="but" data-aksrt-modal-close>' . esc_html__('取消', 'aksrt') . '</button>';
$html .= '<button type="button" class="but c-yellow" data-aksrt-unban-submit>' . esc_html(sprintf(__('确认%s', 'aksrt'), $unban_title)) . '</button>';
$html .= '</div></form></div>';
return $html;
}
// ── 未禁封视图:禁封表单(参考主题禁封弹窗同构) ──
$close_svg = aksrt_user_ban_close_svg();
$html .= '<div class="mb10 edit-header touch"><button type="button" class="close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">' . $close_svg . '</button><b class="flex ac c-red"><span class="toggle-radius mr10 c-red"><i class="fa fas fa-ban" aria-hidden="true"></i></span>' . esc_html__('禁封用户', 'aksrt') . '</b></div>';
// 历史禁封次数提示(参考主题:>1 次时展示禁封记录链接,此处展示计数提示)。
$log_count = 0;
if (class_exists('\AKSRT\User\Restriction')) {
$all = \AKSRT\User\Restriction::get_instance()->get_user_restrictions($user_id, false);
foreach ((array) $all as $r) {
if (in_array((string) ($r['type'] ?? ''), ['ban', 'suspend'], true)) {
$log_count++;
}
}
}
$log_view = $log_count > 1
? '<div class="mb10 flex"><div class="author-set-left" style="min-width: 80px;">' . esc_html__('禁封记录', 'aksrt') . '</div><div class="author-set-right mt6"><span class="em09 c-yellow">' . esc_html(sprintf(__('当前用户已有%d次禁封记录,请谨慎操作', 'aksrt'), $log_count)) . '</span></div></div>'
: '';
$html .= '<form class="aksrt-user-ban-form" data-aksrt-ban-form data-aksrt-ban-mode="ban" data-aksrt-ban-user="' . esc_attr((string) $user_id) . '">';
$html .= '<div class="scroll-y mini-scrollbar max-vh7">';
$html .= '<div class="mt10 mb20">' . $user_row . $log_view . '</div>';
// 禁封方式(1=拉入小黑屋 suspend / 2=禁封用户 ban)。
$html .= '<div class="mb6 muted-2-color">' . esc_html__('禁封方式', 'aksrt') . '</div>';
$html .= '<div class="form-radio form-but-radio">';
$html .= '<label><input type="radio" name="ban" value="1" checked="checked"><span class="p2-10 mr6 but but-radio">' . esc_html__('拉入小黑屋', 'aksrt') . '</span></label>';
$html .= '<label><input type="radio" name="ban" value="2"><span class="p2-10 mr6 but but-radio">' . esc_html__('禁封用户', 'aksrt') . '</span></label>';
$html .= '</div>';
$html .= '<div class="em09 c-red opacity8 mb10">';
$html .= '<div data-aksrt-ban-desc="1">' . esc_html__('将用户拉入小黑屋后,用户将失去所有的发布权限及大部分的操作权限,例如发帖、评论等', 'aksrt') . '</div>';
$html .= '<div data-aksrt-ban-desc="2" style="display:none;">' . esc_html__('禁封的用户将不能登录', 'aksrt') . '</div>';
$html .= '</div>';
// 禁封时间(1/7/14/30/100/永久,默认 7 天,对齐参考主题 checked(7, ...))。
$html .= '<div class="muted-2-color mb6">' . esc_html__('禁封时间', 'aksrt') . '</div>';
$html .= '<div class="form-radio mb10 form-but-radio">';
foreach ([1 => __('1天', 'aksrt'), 7 => __('7天', 'aksrt'), 14 => __('14天', 'aksrt'), 30 => __('30天', 'aksrt'), 100 => __('100天', 'aksrt'), 0 => __('永久', 'aksrt')] as $days => $label) {
$checked = 7 === $days ? ' checked="checked"' : '';
$html .= '<label><input type="radio" name="time_day" value="' . esc_attr((string) $days) . '"' . $checked . '><span class="p2-10 mr6 but but-radio">' . esc_html($label) . '</span></label>';
}
$html .= '</div>';
// 选择禁封原因(预置徽章 + 其它原因)。
$html .= '<div class="muted-2-color mb6">' . esc_html__('选择禁封原因', 'aksrt') . '</div>';
$html .= '<div class="form-radio mb10 form-but-radio">';
$reasons = class_exists('\AKSRT\Settings\UserRuntime') ? (\AKSRT\Settings\UserRuntime::ban_settings()['reasons'] ?? []) : [];
foreach ($reasons as $reason) {
$html .= '<label><input type="radio" name="reason" value="' . esc_attr((string) $reason['name']) . '"><span class="p2-10 mr6 but but-radio">' . esc_html((string) $reason['name']) . '</span></label>';
}
$html .= '<label><input type="radio" name="reason" value="other"><span class="p2-10 mr6 but but-radio">' . esc_html__('其它原因', 'aksrt') . '</span></label>';
$html .= '</div>';
$html .= '<div class="mb20" data-aksrt-ban-reason-other="1" style="display:none;"><input type="text" class="form-control" name="reason_other" tabindex="1" placeholder="' . esc_attr__('请填写禁封原因', 'aksrt') . '"></div>';
// 禁封说明。
$html .= '<div class="muted-2-color mb6">' . esc_html__('禁封说明', 'aksrt') . '</div>';
$html .= '<textarea class="form-control mb20" name="desc" tabindex="2" placeholder="' . esc_attr__('请输入禁封说明或给用户的留言', 'aksrt') . '" rows="2" autoHeight="true" maxHeight="110"></textarea>';
// 删除全部内容。
$html .= '<div class="mb20"><label class="flex jsb ac" style="font-weight:normal;"><input class="hide" name="delete_content" type="checkbox"><div class="flex1 mr20"><div class="muted-2-color mb3">' . esc_html__('删除全部内容', 'aksrt') . '</div><div class="muted-3-color px12">' . esc_html__('将该用户发布的全部文章、帖子、评论移至回收站', 'aksrt') . '</div></div><div class="form-switch flex0"></div></label></div>';
// 禁止用户申诉(含说明联动)。
$html .= '<div class="mb20">';
$html .= '<div class="mb6"><label class="flex jsb ac" style="font-weight:normal;"><input class="hide" name="no_appeal" type="checkbox"><div class="flex1 mr20"><div class="muted-2-color mb3">' . esc_html__('禁止用户申诉', 'aksrt') . '</div><div class="muted-3-color px12">' . esc_html__('开启后用户将无法进行申诉', 'aksrt') . '</div></div><div class="form-switch flex0"></div></label></div>';
$html .= '<div data-aksrt-ban-noappeal-desc style="display:none;"><textarea class="form-control" name="no_appeal_desc" tabindex="1" placeholder="' . esc_attr__('请填写禁止申诉说明', 'aksrt') . '" rows="2"></textarea></div>';
$html .= '</div>';
$html .= '</div>';
$html .= '<div class="mt20 but-average modal-buts">';
$html .= '<button type="button" class="but" data-aksrt-modal-close>' . esc_html__('取消', 'aksrt') . '</button>';
$html .= '<button type="button" class="but c-red" data-aksrt-ban-submit><i class="fa fas fa-ban" aria-hidden="true"></i>' . esc_html__('确认禁封', 'aksrt') . '</button>';
$html .= '</div>';
$html .= '</form></div>';
return $html;
}