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.
This commit is contained in:
2026-09-20 18:42:53 +08:00
parent a1eb545bef
commit 7597972eea
18 changed files with 1080 additions and 374 deletions
@@ -17,6 +17,8 @@ const home = read('src/User/Home.php')
const commentItem = read('inc/helpers/comments/item.php')
const functions = read('functions.php')
const uninstall = read('uninstall.php')
const userBanHelper = read('inc/helpers/user-ban.php')
const userBanJs = read('assets/js/user-ban.js')
test('report REST surface is canonical: submit + users/reports only', () => {
assert.match(userController, /'\/report\/submit'/)
@@ -147,7 +149,7 @@ test('admin report notification links to the real page', () => {
assert.match(reportCenter, /notify_admins_new_report\(/)
assert.match(reportCenter, /notify_reporter_status_changed\(/)
assert.match(reportCenter, /Message::get_instance\(\)->send\(/)
assert.match(reportCenter, /users\.php\?page=aksrt_user_ban&tab=tickets/)
assert.match(reportCenter, /users\.php\?page=aksrt_user_ban&type=report&status=pending/)
})
test('native ban page mirrors the reference theme layout', () => {
@@ -201,10 +203,18 @@ test('native ban page mirrors the reference theme layout', () => {
// 旧的三 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')
})
test('report modal mirrors the reference theme: preset reasons, image evidence, notice, geometry', () => {
@@ -245,38 +255,71 @@ test('comments expose a report entry for other users when reporting is enabled',
assert.match(commentItem, /comment-report-link/)
assert.match(commentItem, /report'\]\['enabled'\]/)
assert.match(commentItem, /\$author_id === \$reporter_id/)
// 下拉菜单(置顶/编辑/批准/删除 + 举报)包含举报项。
assert.match(commentItem, /function aksrt_get_comments_action_dropdown_html[\s\S]*?\$lists \.= aksrt_get_comment_report_html/)
// 下拉菜单(置顶/编辑/批准/删除 + 禁封或举报)末项走禁封优先、否则举报。
assert.match(commentItem, /function aksrt_get_comments_action_dropdown_html[\s\S]*?\$lists \.= aksrt_get_comment_ban_or_report_item/)
// 禁封项(管理员可见):参考主题 comments_action_lists 钩子语义,渲染于 user-ban.php。
assert.match(userBanHelper, /comment-ban-link/)
assert.match(userBanHelper, /data-aksrt-ban="/)
// 举报不作为 comt-meta 独立按钮直接渲染。
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}`)
test('frontend ban modal mirrors the reference theme zib_get_set_user_ban_modal 1:1', () => {
// 禁封方式/禁封时间/预置原因徽章/其它原因/禁封说明/删除全部内容/禁止用户申诉,
// 以及已禁封用户的状态信息与解封留言表单(弹窗经 REST /user-ban/modal 输出)。
for (const label of ['拉入小黑屋', '禁封用户', '禁封方式', '禁封时间', '选择禁封原因', '其它原因', '禁封说明', '删除全部内容', '禁止用户申诉', '请填写禁止申诉说明', '解封用户', '移出小黑屋', '禁封中(禁止登录)', '小黑屋', '给用户的留言', '开始时间', '结束时间', '原因', '说明', '禁封记录', '确认禁封']) {
assert.ok(userBanHelper.includes(label), `ban modal missing reference label: ${label}`)
}
// 禁言(mute)记录在操作列提供“解除禁言”入口,且解除视图能选中 mute 记录
// (此前操作列对 mute 为空、顶部视图只认 ban/suspend,禁言无法从后台解除)。
assert.match(usersMenus, /\$type === 'mute'/)
assert.match(usersMenus, /'mute'\s*=>\s*__\('解除禁言', 'aksrt'\)/)
assert.match(usersMenus, /\$unban && \$current === null/)
// 时长选项 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'/)
assert.match(userBanHelper, /1 => __\('1天', 'aksrt'\)/)
assert.match(userBanHelper, /7 => __\('7天', 'aksrt'\)/)
assert.match(userBanHelper, /100 => __\('100天', 'aksrt'\)/)
assert.match(userBanHelper, /7 === \$days \? ' checked="checked"' : ''/)
// 预置原因来自 UserRuntime::ban_settings(与后台同一数据源)。
assert.match(userBanHelper, /UserRuntime::ban_settings\(\)\['reasons'\]/)
// 勾选禁止申诉必须填写说明(参考主题同校验),落用户 meta 供申诉入口拦截。
assert.match(usersMenus, /_aksrt_no_appeal/)
assert.match(usersMenus, /请填写禁止申诉说明/)
assert.match(userBanHelper, /_aksrt_no_appeal|no_appeal/)
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')
// 弹窗入口按钮契约:[data-aksrt-ban] 委托(封面按钮 / 评论菜单项 / 文章作者菜单项)。
assert.match(userBanHelper, /data-aksrt-ban="/)
assert.match(userBanHelper, /function aksrt_get_user_cover_menu_action_html/)
assert.match(userBanHelper, /function aksrt_get_comment_ban_or_report_item/)
assert.match(userBanHelper, /function aksrt_get_post_author_ban_item/)
// 权限模型(参考主题 zib_current_user_can('set_user_ban') 语义):
// manage_options + 非本人 + 非超级管理员 + 功能开关。
assert.match(userBanHelper, /current_user_can\('manage_options'\)/)
assert.match(userBanHelper, /\$current_id === \$user_id/)
assert.match(userBanHelper, /is_super_admin\(\$user_id\)/)
assert.match(userBanHelper, /user_ban_s/)
// REST 路由:modal 拉取 + set 提交(permission_callback 校验)。
assert.match(userController, /'\/user-ban\/modal'/)
assert.match(userController, /'\/user-ban\/set'/)
// 解封视图多彩横幅(参考主题 zib_get_modal_colorful_header('c-yellow', ...))。
assert.match(userBanHelper, /modal-colorful-header colorful-bg c-yellow/)
// 提交后整页刷新(禁封 reload:true)/ 仅关闭弹窗(解封 hide_modal:true)。
assert.match(userBanJs, /window\.location\.reload\(\)/)
assert.match(userBanJs, /AksrtModalRuntime\.close\('submit'\)/)
assert.match(userBanJs, /data-aksrt-ban-submit/)
assert.match(userBanJs, /data-aksrt-unban-submit/)
// 桌面 600px(参考主题未传 data-class → Bootstrap .modal-dialog 默认宽)
// + min-height 360px(data-height=360);移动端底部抽屉。
assert.match(userBanJs, /MODAL_HEIGHT = 360/)
assert.match(userBanJs, /mobileBottom: true/)
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-user-ban-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /600px/)
// textarea autoHeight 1:1(参考主题 maxHeight 锁高 + 边框补偿)。
assert.match(userBanJs, /getAttribute\('maxHeight'\)/)
// 三个入口模板接线:封面三点菜单 / 评论区三点菜单 / 文章作者 metabox 三点菜单。
const authorHero = read('templates/parts/user/author-hero.php')
assert.match(authorHero, /aksrt_get_user_cover_menu_action_html\(\$user_id\)/)
assert.match(commentItem, /aksrt_get_comment_ban_or_report_item\(\$comment\)/)
const singleComponents = read('inc/helpers/aksrt-single-components.php')
assert.match(singleComponents, /aksrt_get_post_author_ban_item\(/)
// 脚本入队(singular + profile,与 report.js 同组)。
const scripts = read('src/Core/ScriptsPart1b.php')
assert.match(scripts, /'aksrt-user-ban'/)
})
test('uninstall cleans up every ban/report meta key written by the theme', () => {