diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 5d7d4148..5f8d2be5 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -305,40 +305,141 @@ class UsersMenus { echo ''; } + /** + * 禁封限制页 — 1:1 对齐参考主题 zib_get_set_user_ban_modal: + * 禁封方式(拉入小黑屋/禁封用户)+ 禁封时间(1/7/14/30/100/永久)+ + * 选择禁封原因(预置徽章 + 其它原因)+ 禁封说明 + 删除全部内容 + + * 禁止用户申诉(含说明);已禁封用户展示状态信息与解封留言表单。 + * 参考主题无 scope 维度,禁封一律全局。 + */ private function render_restrictions_tab(): void { if (!class_exists('\AKSRT\User\Restriction')) { echo '

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

'; return; } - $svc = \AKSRT\User\Restriction::get_instance(); - $stats = $svc->get_stats(); - echo '

' . esc_html(sprintf( - /* translators: 1 total 2 active 3 banned */ - __('限制记录 %1$d · 生效中 %2$d · 封禁中 %3$d', 'aksrt'), - (int) ($stats['total'] ?? 0), - (int) ($stats['active'] ?? 0), - (int) ($stats['banned'] ?? 0) - )) . '

'; + $svc = \AKSRT\User\Restriction::get_instance(); - echo '

' . esc_html__('添加限制', 'aksrt') . '

'; - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_add_restriction'); - echo '

'; - echo '

'; - echo '

'; - echo '

'; - submit_button(__('添加', 'aksrt'), 'primary', '', false); - echo '
'; + $page_url = admin_url('users.php?page=aksrt_user_ban&tab=restrictions'); + $target_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0; + $unban = isset($_GET['action']) && sanitize_key((string) wp_unslash($_GET['action'])) === 'unban'; + $target = $target_id > 0 ? get_userdata($target_id) : null; + // 页面级样式(wp-admin 不加载前端 kit,就地声明参考主题同款外观) + echo ''; + + // 当前生效中的全局禁封(ban 优先于 suspend,对齐参考主题单一状态语义) + $current = null; + if ($target_id > 0) { + foreach ($svc->get_user_restrictions($target_id, true) as $r) { + if (($r['scope'] ?? '') !== 'global' || !in_array($r['type'], ['ban', 'suspend'], true)) { + continue; + } + if ($current === null || ($r['type'] === 'ban' && $current['type'] !== 'ban')) { + $current = $r; + } + } + } + + echo '
'; + + if (!$target) { + echo '

' . esc_html__('请先通过“用户 ID”定位要禁封或解封的用户(也可从工单列表的“点击此处”进入)。', 'aksrt') . '

'; + } elseif ($current !== null) { + // —— 已禁封视图(参考主题解封弹窗同构):状态信息 + 解封留言表单 —— + $unban_title = $current['type'] === 'ban' ? __('解封用户', 'aksrt') : __('移出小黑屋', 'aksrt'); + $status_text = $current['type'] === 'ban' ? __('禁封中(禁止登录)', 'aksrt') : __('小黑屋禁封中', 'aksrt'); + $end_time = !empty($current['expires_at']) ? (string) $current['expires_at'] : __('永久', 'aksrt'); + echo '

' . esc_html($unban_title) . '

'; + echo '
' . esc_html__('用户', 'aksrt') . '
' . esc_html($target->display_name) . '
'; + echo '
' . esc_html__('状态', 'aksrt') . '
' . esc_html($status_text) . '
'; + echo '
' . esc_html__('开始时间', 'aksrt') . '
' . esc_html((string) ($current['created_at'] ?? '')) . '
'; + echo '
' . esc_html__('结束时间', 'aksrt') . '
' . esc_html($end_time) . '
'; + echo '
' . esc_html__('原因', 'aksrt') . '
' . nl2br(esc_html((string) ($current['reason'] ?? ''))) . '
'; + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_users_unban_' . $target_id); + echo '

'; + echo ''; + echo '
'; + } else { + // —— 禁封表单(参考主题禁封弹窗同构) —— + echo '

' . esc_html__('禁封用户', 'aksrt') . '

'; + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_users_add_restriction'); + + echo '
' . get_avatar($target_id, 40, '', '', ['class' => 'avatar-mini mr6']) . '' . esc_html($target->display_name) . '
'; + + // 禁封方式 + echo '
' . esc_html__('禁封方式', 'aksrt') . '
'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
' . esc_html__('将用户拉入小黑屋后,用户将失去所有的发布权限及大部分的操作权限,例如发帖、评论等', 'aksrt') . '
'; + echo '
' . esc_html__('禁封的用户将不能登录', 'aksrt') . '
'; + echo '
'; + + // 禁封时间 + echo '
' . esc_html__('禁封时间', 'aksrt') . '
'; + echo '
'; + foreach ([1 => __('1天', 'aksrt'), 7 => __('7天', 'aksrt'), 14 => __('14天', 'aksrt'), 30 => __('30天', 'aksrt'), 100 => __('100天', 'aksrt'), 0 => __('永久', 'aksrt')] as $days => $label) { + echo ''; + } + echo '
'; + + // 选择禁封原因(预置原因 + 其它原因) + echo '
' . esc_html__('选择禁封原因', 'aksrt') . '
'; + echo '
'; + foreach (\AKSRT\Settings\UserRuntime::ban_settings()['reasons'] ?? [] as $reason) { + echo ''; + } + echo ''; + echo '
'; + echo ''; + + // 禁封说明 + echo '
' . esc_html__('禁封说明', 'aksrt') . '
'; + echo '

'; + + // 删除全部内容 + echo '
'; + + // 禁止用户申诉 + echo '
'; + echo ''; + + echo ''; + echo '
'; + } + echo '
'; + + // —— 限制记录列表(参考主题列语义:状态/开始/结束/原因) —— global $wpdb; $table = class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('user_restriction') : ''; if ($table === '') { @@ -346,31 +447,63 @@ class UsersMenus { return; } $items = $wpdb->get_results("SELECT * FROM {$table} ORDER BY id DESC LIMIT 100", ARRAY_A); + $now = gmdate('Y-m-d H:i:s'); + echo '

' . esc_html__('限制记录', 'aksrt') . '

'; echo ''; - echo ''; + echo ''; echo ''; if (!$items) { - echo ''; + echo ''; } - foreach ((array) $items as $row) { - $id = (int) ($row['id'] ?? 0); - $user_id = (int) ($row['user_id'] ?? 0); - $user = $user_id > 0 ? get_userdata($user_id) : null; + foreach ((array) $items as $r) { + $type = (string) ($r['type'] ?? ''); + $expired = !empty($r['expires_at']) && (string) $r['expires_at'] <= $now; + $is_global = ($r['scope'] ?? '') === 'global' && in_array($type, ['ban', 'suspend'], true); + if ($expired) { + $status_html = '' . esc_html__('已过期', 'aksrt') . ''; + } elseif ($type === 'ban') { + $status_html = '' . esc_html__('禁封中(禁止登录)', 'aksrt') . ''; + } elseif ($type === 'suspend') { + $status_html = '' . esc_html__('小黑屋禁封中', 'aksrt') . ''; + } elseif ($type === 'mute') { + $status_html = '' . esc_html__('禁言中', 'aksrt') . ''; + } else { + $status_html = '' . esc_html__('警告', 'aksrt') . ''; + } + $r_user_id = (int) ($r['user_id'] ?? 0); + $r_user = $r_user_id > 0 ? get_userdata($r_user_id) : null; echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; } echo '
ID' . esc_html__('用户', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('范围', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('过期', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('禁封状态', 'aksrt') . '' . esc_html__('开始时间', 'aksrt') . '' . esc_html__('结束时间', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无限制记录', 'aksrt') . '
' . esc_html__('暂无限制记录', 'aksrt') . '
' . esc_html((string) $id) . '' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '' . esc_html((string) ($row['type'] ?? '')) . '' . esc_html((string) ($row['scope'] ?? '')) . '' . esc_html((string) ($row['reason'] ?? '')) . '' . esc_html((string) ($row['expires_at'] ?? '—')) . '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_remove_restriction_' . $id); - submit_button(__('移除', 'aksrt'), 'small', '', false); - echo '
' . ($r_user ? esc_html($r_user->display_name) : esc_html((string) $r_user_id)) . '' . $status_html . '' . esc_html((string) ($r['created_at'] ?? '')) . '' . esc_html(!empty($r['expires_at']) ? (string) $r['expires_at'] : __('永久', 'aksrt')) . '' . nl2br(esc_html((string) ($r['reason'] ?? ''))) . ''; + if ($is_global && !$expired) { + echo '' . esc_html(($type === 'ban' ? __('解封', 'aksrt') : __('移出小黑屋', 'aksrt'))) . ''; + } + echo '
'; + + // 依赖联动(参考主题 dependency-box 同语义):禁封方式说明/其它原因/禁止申诉说明 + echo ''; } public function handle_update_report(): void { @@ -390,26 +523,96 @@ class UsersMenus { wp_die(esc_html__('权限不足', 'aksrt')); } check_admin_referer('aksrt_users_add_restriction'); + $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; - $type = isset($_POST['type']) ? sanitize_key((string) wp_unslash($_POST['type'])) : 'warn'; - $scope = isset($_POST['scope']) ? sanitize_key((string) wp_unslash($_POST['scope'])) : 'global'; - $reason = isset($_POST['reason']) ? sanitize_text_field((string) wp_unslash($_POST['reason'])) : ''; - $ok = false; - if ($user_id > 0 && $reason !== '' && class_exists('\AKSRT\User\Restriction')) { - $result = \AKSRT\User\Restriction::get_instance()->restrict($user_id, $type, $reason, $scope, '', get_current_user_id()); - $ok = !empty($result['id']) || !empty($result['success']) || is_array($result); + if ($user_id <= 0 || !get_userdata($user_id)) { + $this->redirect('aksrt_user_ban', 'restriction_failed', ['tab' => 'restrictions']); } - $this->redirect('aksrt_user_ban', $ok ? 'restriction_added' : 'restriction_failed', ['tab' => 'restrictions']); + + // 禁封方式(参考主题语义):1=拉入小黑屋(suspend) / 2=禁封用户(ban,禁止登录) + $ban = isset($_POST['ban']) ? (int) $_POST['ban'] : 1; + $type = $ban === 2 ? 'ban' : 'suspend'; + $time_day = isset($_POST['time_day']) ? (int) $_POST['time_day'] : 0; + $desc = isset($_POST['desc']) ? sanitize_textarea_field((string) wp_unslash($_POST['desc'])) : ''; + + // 原因:预置徽章值或“其它原因”自定义文本 + $reason = isset($_POST['reason']) ? sanitize_text_field((string) wp_unslash($_POST['reason'])) : ''; + if ($reason === '' || $reason === 'other') { + $reason = isset($_POST['reason_other']) ? sanitize_text_field((string) wp_unslash($_POST['reason_other'])) : ''; + } + if ($reason === '') { + $this->redirect('aksrt_user_ban', 'restriction_failed', ['tab' => 'restrictions', 'user_id' => $user_id]); + } + if ($desc !== '') { + $reason .= "\n" . sprintf(__('禁封说明:%s', 'aksrt'), $desc); + } + + $no_appeal = !empty($_POST['no_appeal']); + $no_appeal_desc = isset($_POST['no_appeal_desc']) ? sanitize_textarea_field((string) wp_unslash($_POST['no_appeal_desc'])) : ''; + if ($no_appeal && $no_appeal_desc === '') { + // 参考主题同校验:勾选禁止申诉必须填写说明 + $this->redirect('aksrt_user_ban', 'restriction_failed', ['tab' => 'restrictions', 'user_id' => $user_id]); + } + + $expires = $time_day > 0 ? date('Y-m-d H:i:s', strtotime('+' . $time_day . ' day', current_time('timestamp'))) : ''; + $result = \AKSRT\User\Restriction::get_instance()->restrict($user_id, $type, $reason, 'global', $expires, get_current_user_id()); + $ok = !empty($result['success']); + if ($ok) { + // 禁封状态变更站内信(参考主题 zib_updata_user_ban_msg 同语义) + $msg = $type === 'ban' ? __('您的账号已被禁封,将无法登录。', 'aksrt') : __('您已被拉入小黑屋,发布等相关权限已被限制。', 'aksrt'); + if ($time_day > 0) { + $msg .= ' ' . sprintf(__('期限:%1$d 天,%2$s 起自动解封。', 'aksrt'), $time_day, $expires); + } + \AKSRT\Messages\Message::get_instance()->send($user_id, 'system', $msg, ['title' => __('账号限制', 'aksrt')]); + + if ($no_appeal) { + update_user_meta($user_id, '_aksrt_no_appeal', [ + 'desc' => $no_appeal_desc, + 'at' => current_time('mysql'), + ]); + } + + // 删除全部内容:文章/帖子/评论移至回收站(参考主题同行为) + if (!empty($_POST['delete_content'])) { + $post_ids = get_posts([ + 'author' => $user_id, + 'post_type' => get_post_types(['public' => true]), + 'post_status' => 'publish', + 'numberposts' => -1, + 'fields' => 'ids', + ]); + foreach ($post_ids as $pid) { + wp_trash_post((int) $pid); + } + foreach (get_comments(['user_id' => $user_id, 'status' => 'approve', 'number' => 500]) as $comment) { + wp_trash_comment((int) $comment->comment_ID); + } + } + } + $this->redirect('aksrt_user_ban', $ok ? 'restriction_added' : 'restriction_failed', ['tab' => 'restrictions', 'user_id' => $user_id]); } public function handle_remove_restriction(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } - $id = isset($_POST['restriction_id']) ? (int) $_POST['restriction_id'] : 0; - check_admin_referer('aksrt_users_remove_restriction_' . $id); - $ok = $id > 0 && class_exists('\AKSRT\User\Restriction') && \AKSRT\User\Restriction::get_instance()->remove_restriction($id); - $this->redirect('aksrt_user_ban', $ok ? 'restriction_removed' : 'restriction_failed', ['tab' => 'restrictions']); + $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; + check_admin_referer('aksrt_users_unban_' . $user_id); + $desc = isset($_POST['desc']) ? sanitize_textarea_field((string) wp_unslash($_POST['desc'])) : ''; + + $removed = $user_id > 0 && class_exists('\AKSRT\User\Restriction') + ? \AKSRT\User\Restriction::get_instance()->lift_user_restriction($user_id) + : 0; + delete_user_meta($user_id, '_aksrt_no_appeal'); + + // 解封站内信(含给用户的留言) + $msg = __('您的账号限制已解除。', 'aksrt'); + if ($desc !== '') { + $msg .= ' ' . sprintf(__('留言:%s', 'aksrt'), $desc); + } + \AKSRT\Messages\Message::get_instance()->send($user_id, 'system', $msg, ['title' => __('限制解除', 'aksrt')]); + + $this->redirect('aksrt_user_ban', $removed > 0 ? 'restriction_removed' : 'restriction_failed', ['tab' => 'restrictions', 'user_id' => $user_id]); } public function handle_process_appeal(): void { diff --git a/src/User/BanAppeal.php b/src/User/BanAppeal.php index ec147bc3..316ec390 100644 --- a/src/User/BanAppeal.php +++ b/src/User/BanAppeal.php @@ -97,6 +97,11 @@ class BanAppeal { if (!$banned) { wp_send_json(['error' => 1, 'msg' => __('当前账号未处于禁封状态', 'aksrt')]); } + // 禁封时勾选“禁止用户申诉”的门禁(参考主题 no_appeal 同语义)。 + $no_appeal = get_user_meta($user_id, '_aksrt_no_appeal', true); + if (is_array($no_appeal) && $no_appeal['desc'] !== '') { + wp_send_json(['error' => 1, 'msg' => sprintf(__('当前账号已被限制申诉:%s', 'aksrt'), (string) $no_appeal['desc'])]); + } if (self::get_status($user_id) === 'pending') { wp_send_json(['error' => 1, 'msg' => __('申诉正在处理中,请勿重复提交', 'aksrt')]); } diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index 57d6fc76..d679718f 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -251,8 +251,31 @@ test('comments expose a report entry for other users when reporting is enabled', assert.doesNotMatch(commentItem, /\$report\s*=\s*aksrt_get_comment_report_html\(\$comment\)/) }) +test('restrictions tab mirrors the reference ban modal 1:1', () => { + // 禁封方式/禁封时间/预置原因徽章/禁封说明/删除全部内容/禁止用户申诉, + // 以及已禁封用户的状态信息与解封留言表单(zib_get_set_user_ban_modal 同构)。 + for (const label of ['拉入小黑屋', '禁封用户', '禁封方式', '禁封时间', '选择禁封原因', '其它原因', '禁封说明', '删除全部内容', '禁止用户申诉', '请填写禁止申诉说明', '解封用户', '移出小黑屋', '小黑屋禁封中', '禁封中(禁止登录)', '给用户的留言', '开始时间', '结束时间']) { + assert.ok(usersMenus.includes(label), `restrictions tab missing reference label: ${label}`) + } + // 时长选项 1/7/14/30/100/永久(默认 7 天);类型映射:小黑屋→suspend,禁封→ban。 + assert.match(usersMenus, /1 => __\('1天', 'aksrt'\)/) + assert.match(usersMenus, /7 => __\('7天', 'aksrt'\)/) + assert.match(usersMenus, /100 => __\('100天', 'aksrt'\)/) + assert.match(usersMenus, /checked\(7, \$days, false\)/) + assert.match(usersMenus, /\$type\s*=\s*\$ban === 2 \? 'ban' : 'suspend'/) + // 勾选禁止申诉必须填写说明(参考主题同校验),落用户 meta 供申诉入口拦截。 + assert.match(usersMenus, /_aksrt_no_appeal/) + assert.match(usersMenus, /请填写禁止申诉说明/) + const appeal = read('src/User/BanAppeal.php') + assert.match(appeal, /_aksrt_no_appeal/) + // 解封流程:按用户解封 + 留言站内信(旧 restriction_id 通道退役)。 + assert.match(usersMenus, /wp_nonce_field\('aksrt_users_unban_' \. \$target_id\)/) + assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/) + assert.ok(!usersMenus.includes('name="restriction_id"'), 'per-record removal superseded by per-user unban') +}) + test('uninstall cleans up every ban/report meta key written by the theme', () => { - for (const key of ['_aksrt_active_restrictions', '_aksrt_is_banned', 'aksrt_user_ban_state']) { + for (const key of ['_aksrt_active_restrictions', '_aksrt_is_banned', '_aksrt_no_appeal', 'aksrt_user_ban_state']) { assert.ok(uninstall.includes(`'${key}'`), `missing uninstall cleanup: ${key}`) } }) diff --git a/uninstall.php b/uninstall.php index 1d35aede..d561209a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -67,6 +67,7 @@ $meta_keys = [ 'aksrt_unread_message_count', '_aksrt_active_restrictions', '_aksrt_is_banned', + '_aksrt_no_appeal', 'aksrt_user_ban_state', '_aksrt_favorite_posts', 'aksrt_wechat_openid',