feat(messages): redesign native message admin page
- Rebuild users.php?page=aksrt_messages: overview stats, collapsible compose card (users list / role recipients), read-state views, type/recipient/keyword filters, bulk mark-read/delete, shared AdminListPagination - Extract NotificationAdminQuery / AdminBroadcast / MessageTypes under src/Messages; page logic lives in UsersMenusMessages trait - Page-scoped assets/css/admin/messages.css - Remove dead aksrt_notification_type_label() and Message::get_all_notifications() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbb41547e2
commit
6681b0c306
9 files changed
+1081
-110
No files matched your search
@@ -0,0 +1,175 @@
|
||||
/* 消息管理(users.php?page=aksrt_messages)——仅在本页入队 */
|
||||
|
||||
.aksrt-messages-admin {
|
||||
--aksrt-msg-border: #dcdcde;
|
||||
--aksrt-msg-muted: #646970;
|
||||
--aksrt-msg-soft: #f6f7f7;
|
||||
--aksrt-msg-accent: #2271b1;
|
||||
}
|
||||
|
||||
.aksrt-messages-admin__muted { color: var(--aksrt-msg-muted); }
|
||||
.aksrt-messages-admin__sub { margin-top: 3px; color: var(--aksrt-msg-muted); font-size: 12px; }
|
||||
.aksrt-messages-admin__sub code { padding: 0 4px; font-size: 12px; background: var(--aksrt-msg-soft); }
|
||||
|
||||
/* ---- 概览 ---- */
|
||||
.aksrt-messages-admin__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.aksrt-messages-admin__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--aksrt-msg-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aksrt-messages-admin__stat > .dashicons {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 20px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
color: var(--aksrt-msg-tone, var(--aksrt-msg-accent));
|
||||
background: color-mix(in srgb, var(--aksrt-msg-tone, var(--aksrt-msg-accent)) 12%, #fff);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.aksrt-messages-admin__stat div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.aksrt-messages-admin__stat strong { font-size: 20px; line-height: 1.3; }
|
||||
.aksrt-messages-admin__stat div span { color: var(--aksrt-msg-muted); }
|
||||
.aksrt-messages-admin__stat.is-unread { --aksrt-msg-tone: #d63638; }
|
||||
.aksrt-messages-admin__stat.is-today { --aksrt-msg-tone: #00a32a; }
|
||||
.aksrt-messages-admin__stat.is-recipients { --aksrt-msg-tone: #8c5bd0; }
|
||||
|
||||
/* ---- 代发 ---- */
|
||||
.aksrt-messages-admin__compose {
|
||||
margin: 16px 0 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--aksrt-msg-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aksrt-messages-admin__compose > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--aksrt-msg-accent);
|
||||
list-style: none;
|
||||
}
|
||||
.aksrt-messages-admin__compose > summary::-webkit-details-marker { display: none; }
|
||||
.aksrt-messages-admin__compose[open] > summary { border-bottom: 1px solid var(--aksrt-msg-border); }
|
||||
.aksrt-messages-admin__compose-form { margin: 0; padding: 14px; }
|
||||
|
||||
.aksrt-messages-admin__modes { display: flex; gap: 16px; margin: 0 0 12px; padding: 0; border: 0; }
|
||||
.aksrt-messages-admin__modes legend { float: left; margin-right: 8px; font-weight: 600; }
|
||||
|
||||
.aksrt-messages-admin__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
.aksrt-messages-admin__grid label { display: flex; flex-direction: column; gap: 4px; }
|
||||
.aksrt-messages-admin__grid label > span { font-weight: 600; }
|
||||
.aksrt-messages-admin__grid label > small { color: var(--aksrt-msg-muted); }
|
||||
.aksrt-messages-admin__grid .is-wide { grid-column: 1 / -1; }
|
||||
.aksrt-messages-admin__grid input,
|
||||
.aksrt-messages-admin__grid select,
|
||||
.aksrt-messages-admin__grid textarea { width: 100%; max-width: none; }
|
||||
|
||||
/* 收件人模式切换:纯 CSS,无 JS 时两组字段都可见、按所选模式生效。 */
|
||||
.aksrt-messages-admin__compose-form:has(input[name="recipient_mode"][value="users"]:checked) .is-role,
|
||||
.aksrt-messages-admin__compose-form:has(input[name="recipient_mode"][value="role"]:checked) .is-users { display: none; }
|
||||
|
||||
.aksrt-messages-admin__compose-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
/* ---- 筛选 ---- */
|
||||
.aksrt-messages-admin__filters {
|
||||
clear: both;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.aksrt-messages-admin__filters input[type="text"],
|
||||
.aksrt-messages-admin__filters input[type="search"] { min-width: 200px; }
|
||||
|
||||
/* ---- 列表 ---- */
|
||||
.aksrt-messages-admin__danger.button { color: #b32d2e; border-color: #b32d2e; }
|
||||
.aksrt-messages-admin__danger.button:hover,
|
||||
.aksrt-messages-admin__danger.button:focus { color: #fff; background: #b32d2e; border-color: #b32d2e; }
|
||||
|
||||
.aksrt-messages-admin__table .column-title { width: 40%; }
|
||||
.aksrt-messages-admin__table .column-type { width: 12%; }
|
||||
.aksrt-messages-admin__table .column-user { width: 18%; }
|
||||
.aksrt-messages-admin__table .column-read { width: 8%; }
|
||||
.aksrt-messages-admin__table .column-time { width: 14%; }
|
||||
.aksrt-messages-admin__table td { vertical-align: middle; }
|
||||
.aksrt-messages-admin__table .row-title { word-break: break-word; }
|
||||
.aksrt-messages-admin__table tr.is-unread .row-title { font-weight: 700; }
|
||||
.aksrt-messages-admin__table tr.is-unread > th:first-child { box-shadow: inset 3px 0 0 var(--aksrt-msg-accent); }
|
||||
.aksrt-messages-admin__desc { margin-top: 3px; color: #3c434a; word-break: break-word; }
|
||||
|
||||
.aksrt-messages-admin__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 32px 0;
|
||||
color: var(--aksrt-msg-muted);
|
||||
}
|
||||
.aksrt-messages-admin__empty .dashicons { width: 32px; height: 32px; font-size: 32px; opacity: .5; }
|
||||
|
||||
.aksrt-msg-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: var(--aksrt-msg-tone, #646970);
|
||||
background: color-mix(in srgb, var(--aksrt-msg-tone, #646970) 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--aksrt-msg-tone, #646970) 30%, #fff);
|
||||
}
|
||||
.aksrt-msg-badge:hover,
|
||||
.aksrt-msg-badge:focus { color: var(--aksrt-msg-tone, #646970); border-color: var(--aksrt-msg-tone, #646970); }
|
||||
.aksrt-msg-badge.is-info { --aksrt-msg-tone: #2271b1; }
|
||||
.aksrt-msg-badge.is-success { --aksrt-msg-tone: #00a32a; }
|
||||
.aksrt-msg-badge.is-danger { --aksrt-msg-tone: #d63638; }
|
||||
.aksrt-msg-badge.is-social { --aksrt-msg-tone: #8c5bd0; }
|
||||
|
||||
.aksrt-msg-state { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; white-space: nowrap; }
|
||||
.aksrt-msg-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.aksrt-msg-state.is-unread { color: var(--aksrt-msg-accent); font-weight: 600; }
|
||||
.aksrt-msg-state.is-read { color: var(--aksrt-msg-muted); }
|
||||
|
||||
.aksrt-msg-user { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; text-decoration: none; }
|
||||
.aksrt-msg-user img { flex: none; border-radius: 50%; }
|
||||
.aksrt-msg-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.aksrt-messages-admin__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 782px) {
|
||||
.aksrt-messages-admin__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.aksrt-messages-admin__compose-foot { flex-direction: column; align-items: flex-start; }
|
||||
.aksrt-messages-admin__table .column-type,
|
||||
.aksrt-messages-admin__table .column-read { display: none; }
|
||||
}
|
||||
@@ -50,25 +50,6 @@ function aksrt_get_user_notifications(int $user_id = 0): array {
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_notification_type_label')) :
|
||||
/** 通知类型中文标签 */
|
||||
function aksrt_notification_type_label(string $type): string {
|
||||
$map = [
|
||||
'comment_reply' => __('评论回复', 'aksrt'),
|
||||
'comment_approved' => __('评论审核', 'aksrt'),
|
||||
'comment' => __('文章评论', 'aksrt'),
|
||||
'download_ready' => __('下载就绪', 'aksrt'),
|
||||
'post_favorite' => __('文章收藏', 'aksrt'),
|
||||
'post_like' => __('文章点赞', 'aksrt'),
|
||||
'comment_like' => __('评论点赞', 'aksrt'),
|
||||
'followed' => __('新增关注', 'aksrt'),
|
||||
'pending_comments' => __('待审核评论', 'aksrt'),
|
||||
'notice' => __('通知', 'aksrt'),
|
||||
];
|
||||
return $map[$type] ?? $type;
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_get_user_notification_count')) :
|
||||
/** 获取用户提醒总数 */
|
||||
function aksrt_get_user_notification_count(int $user_id = 0): int {
|
||||
|
||||
@@ -14,6 +14,7 @@ class UsersMenus {
|
||||
use UsersMenusInviteCode;
|
||||
use UsersMenusTickets;
|
||||
use UsersMenusTicketsCenter;
|
||||
use UsersMenusMessages;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
|
||||
@@ -34,6 +35,7 @@ class UsersMenus {
|
||||
add_action('admin_menu', [$this, 'register_menus']);
|
||||
add_action('admin_notices', [$this, 'pending_notices']);
|
||||
add_action('admin_post_aksrt_users_send_message', [$this, 'handle_send_message']);
|
||||
add_action('admin_post_aksrt_users_bulk_messages', [$this, 'handle_bulk_messages']);
|
||||
add_action('admin_post_aksrt_users_review_verify', [$this, 'handle_review_verify']);
|
||||
add_action('admin_post_aksrt_users_delete_verify', [$this, 'handle_delete_verify']);
|
||||
add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']);
|
||||
@@ -41,6 +43,7 @@ class UsersMenus {
|
||||
add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']);
|
||||
add_action('admin_post_aksrt_ticket_update', [$this, 'handle_ticket_update']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']);
|
||||
}
|
||||
|
||||
public function register_menus(): void {
|
||||
@@ -175,8 +178,6 @@ class UsersMenus {
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_users_msg']));
|
||||
$map = [
|
||||
'message_sent' => __('消息已发送。', 'aksrt'),
|
||||
'message_failed' => __('消息发送失败。', 'aksrt'),
|
||||
'verify_updated' => __('认证状态已更新。', 'aksrt'),
|
||||
'verify_deleted' => __('认证记录已删除。', 'aksrt'),
|
||||
'verify_failed' => __('认证审核失败。', 'aksrt'),
|
||||
@@ -216,88 +217,6 @@ class UsersMenus {
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Messages
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function render_messages_page(): void {
|
||||
$this->ensure_cap();
|
||||
echo '<div class="wrap"><h1>' . esc_html__('消息管理', 'aksrt') . '</h1>';
|
||||
$this->flash();
|
||||
|
||||
echo '<h2>' . esc_html__('发送系统消息', 'aksrt') . '</h2>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-bottom:20px;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_send_message" />';
|
||||
wp_nonce_field('aksrt_users_send_message');
|
||||
echo '<p><label>' . esc_html__('用户 ID(0=广播给管理员自己测试)', 'aksrt') . '<br><input type="number" name="user_id" value="0" min="0" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('标题', 'aksrt') . '<br><input type="text" name="title" class="regular-text" required /></label></p>';
|
||||
echo '<p><label>' . esc_html__('内容', 'aksrt') . '<br><textarea name="content" class="large-text" rows="4" required></textarea></label></p>';
|
||||
submit_button(__('发送', 'aksrt'));
|
||||
echo '</form>';
|
||||
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 30;
|
||||
$offset = ($page_num - 1) * $per_page;
|
||||
$table = class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('notification') : '';
|
||||
|
||||
if ($table === '') {
|
||||
echo '<div class="notice notice-warning"><p>' . esc_html__('通知表不可用。', 'aksrt') . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
|
||||
$items = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$per_page,
|
||||
$offset
|
||||
), ARRAY_A);
|
||||
$pages = max(1, (int) ceil(max(1, $total) / $per_page));
|
||||
|
||||
echo '<h2>' . esc_html__('最近消息', 'aksrt') . '</h2>';
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('类型', 'aksrt') . '</th><th>' . esc_html__('标题', 'aksrt') . '</th><th>' . esc_html__('已读', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="6">' . esc_html__('暂无消息', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ((array) $items as $row) {
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) ($row['id'] ?? '')) . '</td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['title'] ?? $row['description'] ?? '')) . '</td>';
|
||||
echo '<td>' . (!empty($row['is_read']) ? esc_html__('是', 'aksrt') : esc_html__('否', 'aksrt')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->pagination($pages, $page_num);
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public function handle_send_message(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
check_admin_referer('aksrt_users_send_message');
|
||||
|
||||
$user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
|
||||
$title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : '';
|
||||
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
|
||||
if ($user_id <= 0) {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
|
||||
$ok = false;
|
||||
if ($content !== '' && class_exists('\AKSRT\Messages\Message')) {
|
||||
$ok = \AKSRT\Messages\Message::get_instance()->send($user_id, 'system', $content, ['title' => $title !== '' ? $title : $content]);
|
||||
}
|
||||
$this->redirect('aksrt_messages', $ok ? 'message_sent' : 'message_failed');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Ban / reports
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
/**
|
||||
* 消息管理后台页(users.php → aksrt_messages)。
|
||||
*
|
||||
* 单页承载:概览卡片 + 代发系统消息(折叠卡片,按用户列表或角色投递)+ 已读状态视图
|
||||
* + 类型 / 收件人 / 关键词筛选 + 全站通知列表(批量标记已读 / 删除)+ 共享分页条。
|
||||
* 查询与写操作在 AKSRT\Messages\NotificationAdminQuery / AdminBroadcast,这里只做页面与路由。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Messages\AdminBroadcast;
|
||||
use AKSRT\Messages\MessageTypes;
|
||||
use AKSRT\Messages\NotificationAdminQuery;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusMessages {
|
||||
|
||||
private const MESSAGE_PAGE_SLUG = 'aksrt_messages';
|
||||
|
||||
/** 页面专属样式,只在本页入队。 */
|
||||
public function enqueue_message_assets(string $hook): void {
|
||||
if ($hook !== 'users_page_' . self::MESSAGE_PAGE_SLUG) {
|
||||
return;
|
||||
}
|
||||
$css = '/assets/css/admin/messages.css';
|
||||
if (is_file(AKSRT_DIR . $css)) {
|
||||
wp_enqueue_style('aksrt-admin-messages', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css));
|
||||
}
|
||||
}
|
||||
|
||||
public function render_messages_page(): void {
|
||||
$this->ensure_cap();
|
||||
$filters = $this->message_filters();
|
||||
$paged = max(1, absint($_GET['paged'] ?? 1));
|
||||
|
||||
echo '<div class="wrap aksrt-messages-admin">';
|
||||
echo '<h1 class="wp-heading-inline">' . esc_html__('消息管理', 'aksrt') . '</h1>';
|
||||
printf(
|
||||
'<a class="page-title-action" href="%s">%s</a>',
|
||||
esc_url(admin_url('admin.php?page=aksrt-settings#/users/notifications')),
|
||||
esc_html__('通知配置', 'aksrt')
|
||||
);
|
||||
echo '<hr class="wp-header-end">';
|
||||
$this->render_message_notices();
|
||||
|
||||
if (NotificationAdminQuery::table() === '') {
|
||||
echo '<div class="notice notice-warning"><p>' . esc_html__('通知表不可用。', 'aksrt') . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->message_query_filters($filters);
|
||||
$result = NotificationAdminQuery::paginate($query, $paged);
|
||||
|
||||
$this->render_message_overview();
|
||||
$this->render_message_compose();
|
||||
$this->render_message_views($filters, NotificationAdminQuery::status_counts($query));
|
||||
$this->render_message_filter_form($filters);
|
||||
$this->render_message_table($result['items'], $filters, $result['page'], $query['user_id'] < 0);
|
||||
AdminListPagination::render(
|
||||
self::MESSAGE_PAGE_SLUG,
|
||||
$result['total'],
|
||||
$result['page'],
|
||||
NotificationAdminQuery::PER_PAGE,
|
||||
$this->message_filter_args($filters),
|
||||
'users.php'
|
||||
);
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 写操作
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function handle_send_message(): void {
|
||||
$this->ensure_cap();
|
||||
check_admin_referer('aksrt_users_send_message');
|
||||
|
||||
$mode = sanitize_key((string) wp_unslash($_POST['recipient_mode'] ?? 'users'));
|
||||
$title = trim(sanitize_text_field((string) wp_unslash($_POST['title'] ?? '')));
|
||||
$content = trim(sanitize_textarea_field((string) wp_unslash($_POST['content'] ?? '')));
|
||||
$target_url = esc_url_raw(trim((string) wp_unslash($_POST['target_url'] ?? '')));
|
||||
|
||||
if ($content === '') {
|
||||
$this->message_fail(__('请填写消息内容。', 'aksrt'));
|
||||
}
|
||||
if (mb_strlen($content) > AdminBroadcast::CONTENT_MAX) {
|
||||
$this->message_fail(sprintf(__('消息内容不能超过 %d 字。', 'aksrt'), AdminBroadcast::CONTENT_MAX));
|
||||
}
|
||||
$title = mb_substr($title !== '' ? $title : $content, 0, AdminBroadcast::TITLE_MAX);
|
||||
|
||||
$unknown = 0;
|
||||
if ($mode === 'role') {
|
||||
$ids = AdminBroadcast::resolve_role(sanitize_key((string) wp_unslash($_POST['role'] ?? '')));
|
||||
} else {
|
||||
$resolved = AdminBroadcast::resolve_users(sanitize_textarea_field((string) wp_unslash($_POST['users'] ?? '')));
|
||||
$ids = is_wp_error($resolved) ? $resolved : $resolved['ids'];
|
||||
$unknown = is_wp_error($resolved) ? 0 : count($resolved['unknown']);
|
||||
}
|
||||
if (is_wp_error($ids)) {
|
||||
$this->message_fail($ids->get_error_message());
|
||||
}
|
||||
if ($ids === []) {
|
||||
$this->message_fail(__('没有匹配到任何有效收件人。', 'aksrt'));
|
||||
}
|
||||
|
||||
$sent = AdminBroadcast::send($ids, $title, $content, $target_url);
|
||||
$this->message_redirect([
|
||||
'aksrt_msg' => $sent['sent'] > 0 ? 'sent' : 'send_failed',
|
||||
'aksrt_msg_n' => $sent['sent'],
|
||||
'aksrt_msg_f' => $sent['failed'],
|
||||
'aksrt_msg_u' => $unknown,
|
||||
]);
|
||||
}
|
||||
|
||||
public function handle_bulk_messages(): void {
|
||||
$this->ensure_cap();
|
||||
check_admin_referer('aksrt_users_bulk_messages');
|
||||
|
||||
$op = sanitize_key((string) wp_unslash($_POST['bulk_action'] ?? ''));
|
||||
$ids = isset($_POST['ids']) && is_array($_POST['ids']) ? array_map('absint', wp_unslash($_POST['ids'])) : [];
|
||||
$back = $this->message_filter_args($this->message_filters($_POST));
|
||||
$paged = absint($_POST['paged'] ?? 0);
|
||||
if ($paged > 1) {
|
||||
$back['paged'] = $paged;
|
||||
}
|
||||
|
||||
if (!in_array($op, ['delete', 'mark_read'], true) || array_filter($ids) === []) {
|
||||
$this->message_redirect(array_merge($back, ['aksrt_msg' => 'bulk_none']));
|
||||
}
|
||||
$count = $op === 'delete' ? NotificationAdminQuery::delete($ids) : NotificationAdminQuery::mark_read($ids);
|
||||
$this->message_redirect(array_merge($back, [
|
||||
'aksrt_msg' => $op === 'delete' ? 'deleted' : 'marked',
|
||||
'aksrt_msg_n' => $count,
|
||||
]));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 区块渲染
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
private function render_message_notices(): void {
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? ''));
|
||||
if ($code === '') {
|
||||
return;
|
||||
}
|
||||
$n = absint($_GET['aksrt_msg_n'] ?? 0);
|
||||
$error = false;
|
||||
switch ($code) {
|
||||
case 'sent':
|
||||
$text = sprintf(__('已发送 %d 条消息。', 'aksrt'), $n);
|
||||
$failed = absint($_GET['aksrt_msg_f'] ?? 0);
|
||||
$unknown = absint($_GET['aksrt_msg_u'] ?? 0);
|
||||
if ($failed > 0) {
|
||||
$text .= ' ' . sprintf(__('投递失败 %d 条。', 'aksrt'), $failed);
|
||||
}
|
||||
if ($unknown > 0) {
|
||||
$text .= ' ' . sprintf(__('%d 个收件人未找到,已跳过。', 'aksrt'), $unknown);
|
||||
}
|
||||
break;
|
||||
case 'send_failed':
|
||||
$error = true;
|
||||
$text = isset($_GET['aksrt_msg_error'])
|
||||
? sanitize_text_field((string) wp_unslash($_GET['aksrt_msg_error']))
|
||||
: __('消息发送失败。', 'aksrt');
|
||||
break;
|
||||
case 'deleted':
|
||||
$text = sprintf(__('已删除 %d 条消息。', 'aksrt'), $n);
|
||||
break;
|
||||
case 'marked':
|
||||
$text = sprintf(__('已将 %d 条消息标记为已读。', 'aksrt'), $n);
|
||||
break;
|
||||
case 'bulk_none':
|
||||
$error = true;
|
||||
$text = __('请先勾选消息并选择操作。', 'aksrt');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
printf(
|
||||
'<div class="notice %s is-dismissible"><p>%s</p></div>',
|
||||
$error ? 'notice-error' : 'notice-success',
|
||||
esc_html($text)
|
||||
);
|
||||
}
|
||||
|
||||
private function render_message_overview(): void {
|
||||
$stats = NotificationAdminQuery::overview();
|
||||
$cards = [
|
||||
['total', __('消息总数', 'aksrt'), 'dashicons-email-alt'],
|
||||
['unread', __('未读', 'aksrt'), 'dashicons-marker'],
|
||||
['today', __('今日新增', 'aksrt'), 'dashicons-calendar-alt'],
|
||||
['recipients', __('涉及用户', 'aksrt'), 'dashicons-groups'],
|
||||
];
|
||||
echo '<ul class="aksrt-messages-admin__stats">';
|
||||
foreach ($cards as [$key, $label, $icon]) {
|
||||
printf(
|
||||
'<li class="aksrt-messages-admin__stat is-%1$s"><span class="dashicons %2$s" aria-hidden="true"></span><div><strong>%3$s</strong><span>%4$s</span></div></li>',
|
||||
esc_attr($key),
|
||||
esc_attr($icon),
|
||||
esc_html(number_format_i18n($stats[$key])),
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
private function render_message_compose(): void {
|
||||
$open = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? '')) === 'send_failed';
|
||||
|
||||
echo '<details class="aksrt-messages-admin__compose"' . ($open ? ' open' : '') . '>';
|
||||
echo '<summary><span class="dashicons dashicons-megaphone" aria-hidden="true"></span>' . esc_html__('发送系统消息', 'aksrt') . '</summary>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-messages-admin__compose-form">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_send_message" />';
|
||||
wp_nonce_field('aksrt_users_send_message');
|
||||
|
||||
echo '<fieldset class="aksrt-messages-admin__modes"><legend>' . esc_html__('收件人', 'aksrt') . '</legend>';
|
||||
printf(
|
||||
'<label><input type="radio" name="recipient_mode" value="users" checked /> %s</label>',
|
||||
esc_html__('指定用户', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label><input type="radio" name="recipient_mode" value="role" /> %s</label>',
|
||||
esc_html__('按角色', 'aksrt')
|
||||
);
|
||||
echo '</fieldset>';
|
||||
|
||||
echo '<div class="aksrt-messages-admin__grid">';
|
||||
printf(
|
||||
'<label class="is-wide is-users"><span>%s</span><textarea name="users" rows="2" class="large-text" placeholder="%s"></textarea><small>%s</small></label>',
|
||||
esc_html__('用户', 'aksrt'),
|
||||
esc_attr__('用户 ID / 用户名 / 邮箱,逗号或换行分隔', 'aksrt'),
|
||||
esc_html(sprintf(__('单次最多 %d 位。', 'aksrt'), AdminBroadcast::MAX_USERS))
|
||||
);
|
||||
echo '<label class="is-wide is-role"><span>' . esc_html__('角色', 'aksrt') . '</span><select name="role">';
|
||||
foreach (wp_roles()->get_names() as $role => $name) {
|
||||
printf('<option value="%s">%s</option>', esc_attr($role), esc_html(translate_user_role($name)));
|
||||
}
|
||||
echo '</select><small>' . esc_html(sprintf(__('角色用户超过 %d 人时拒绝发送。', 'aksrt'), AdminBroadcast::MAX_ROLE_USERS)) . '</small></label>';
|
||||
printf(
|
||||
'<label><span>%s</span><input type="text" name="title" maxlength="%d" class="regular-text" placeholder="%s" /></label>',
|
||||
esc_html__('标题', 'aksrt'),
|
||||
(int) AdminBroadcast::TITLE_MAX,
|
||||
esc_attr__('留空则取内容开头', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label><span>%s</span><input type="url" name="target_url" class="regular-text" placeholder="https://" /></label>',
|
||||
esc_html__('跳转链接(可选)', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="is-wide"><span>%s</span><textarea name="content" rows="4" maxlength="%d" required class="large-text"></textarea></label>',
|
||||
esc_html__('内容', 'aksrt'),
|
||||
(int) AdminBroadcast::CONTENT_MAX
|
||||
);
|
||||
echo '</div>';
|
||||
printf(
|
||||
'<p class="aksrt-messages-admin__compose-foot"><span class="description">%s</span><button type="submit" class="button button-primary">%s</button></p>',
|
||||
esc_html__('以「系统通知」类型逐人投递;邮件 / 公众号提醒按通知配置同步触发。', 'aksrt'),
|
||||
esc_html__('发送', 'aksrt')
|
||||
);
|
||||
echo '</form></details>';
|
||||
}
|
||||
|
||||
/** @param array{all:int,unread:int,read:int} $counts */
|
||||
private function render_message_views(array $filters, array $counts): void {
|
||||
$views = [
|
||||
'' => [__('全部', 'aksrt'), $counts['all']],
|
||||
'unread' => [__('未读', 'aksrt'), $counts['unread']],
|
||||
'read' => [__('已读', 'aksrt'), $counts['read']],
|
||||
];
|
||||
$args = $this->message_filter_args($filters);
|
||||
unset($args['status']);
|
||||
|
||||
echo '<ul class="subsubsub">';
|
||||
$last = array_key_last($views);
|
||||
foreach ($views as $key => [$label, $count]) {
|
||||
printf(
|
||||
'<li><a href="%s"%s>%s <span class="count">(%s)</span></a>%s</li>',
|
||||
esc_url($this->message_page_url($key === '' ? $args : array_merge($args, ['status' => $key]))),
|
||||
$filters['status'] === $key ? ' class="current" aria-current="page"' : '',
|
||||
esc_html($label),
|
||||
esc_html(number_format_i18n($count)),
|
||||
$key === $last ? '' : ' |'
|
||||
);
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
private function render_message_filter_form(array $filters): void {
|
||||
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-messages-admin__filters">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::MESSAGE_PAGE_SLUG) . '" />';
|
||||
if ($filters['status'] !== '') {
|
||||
echo '<input type="hidden" name="status" value="' . esc_attr($filters['status']) . '" />';
|
||||
}
|
||||
echo '<label class="screen-reader-text" for="aksrt-msg-filter-type">' . esc_html__('按类型筛选', 'aksrt') . '</label>';
|
||||
echo '<select name="type" id="aksrt-msg-filter-type"><option value="">' . esc_html__('全部类型', 'aksrt') . '</option>';
|
||||
foreach (NotificationAdminQuery::types() as $type) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($type),
|
||||
selected($filters['type'], $type, false),
|
||||
esc_html(MessageTypes::label($type))
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-user">%s</label><input type="text" id="aksrt-msg-filter-user" name="user" value="%s" placeholder="%s" />',
|
||||
esc_html__('按收件人筛选', 'aksrt'),
|
||||
esc_attr($filters['user']),
|
||||
esc_attr__('收件人 ID / 用户名 / 邮箱', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-s">%s</label><input type="search" id="aksrt-msg-filter-s" name="s" value="%s" placeholder="%s" />',
|
||||
esc_html__('搜索消息', 'aksrt'),
|
||||
esc_attr($filters['s']),
|
||||
esc_attr__('标题 / 内容', 'aksrt')
|
||||
);
|
||||
submit_button(__('筛选', 'aksrt'), '', '', false);
|
||||
if ($filters['type'] !== '' || $filters['user'] !== '' || $filters['s'] !== '') {
|
||||
printf(
|
||||
'<a class="button-link" href="%s">%s</a>',
|
||||
esc_url($this->message_page_url($filters['status'] === '' ? [] : ['status' => $filters['status']])),
|
||||
esc_html__('清除筛选', 'aksrt')
|
||||
);
|
||||
}
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private function render_message_table(array $items, array $filters, int $page, bool $user_missing): void {
|
||||
$user_ids = array_values(array_unique(array_filter(array_map(static fn($row): int => (int) ($row['user_id'] ?? 0), $items))));
|
||||
if ($user_ids !== []) {
|
||||
cache_users($user_ids);
|
||||
}
|
||||
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-messages-admin__list">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_bulk_messages" />';
|
||||
wp_nonce_field('aksrt_users_bulk_messages');
|
||||
foreach ($this->message_filter_args($filters) as $key => $value) {
|
||||
printf('<input type="hidden" name="%s" value="%s" />', esc_attr($key), esc_attr((string) $value));
|
||||
}
|
||||
echo '<input type="hidden" name="paged" value="' . esc_attr((string) $page) . '" />';
|
||||
|
||||
echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
|
||||
printf(
|
||||
'<button type="submit" name="bulk_action" value="mark_read" class="button">%s</button> ',
|
||||
esc_html__('标记已读', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<button type="submit" name="bulk_action" value="delete" class="button aksrt-messages-admin__danger" onclick="return confirm(\'%s\');">%s</button>',
|
||||
esc_js(__('确定删除所选消息?删除后用户收件箱中也将不再显示。', 'aksrt')),
|
||||
esc_html__('删除', 'aksrt')
|
||||
);
|
||||
echo '</div><br class="clear"></div>';
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-messages-admin__table"><thead><tr>';
|
||||
echo '<td class="manage-column column-cb check-column"><label class="screen-reader-text" for="cb-select-all-1">' . esc_html__('全选', 'aksrt') . '</label><input id="cb-select-all-1" type="checkbox" /></td>';
|
||||
echo '<th class="column-title">' . esc_html__('消息', 'aksrt') . '</th>';
|
||||
echo '<th class="column-type">' . esc_html__('类型', 'aksrt') . '</th>';
|
||||
echo '<th class="column-user">' . esc_html__('收件人', 'aksrt') . '</th>';
|
||||
echo '<th class="column-read">' . esc_html__('状态', 'aksrt') . '</th>';
|
||||
echo '<th class="column-time">' . esc_html__('发送时间', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if ($items === []) {
|
||||
$hint = $user_missing
|
||||
? __('没有找到该收件人。', 'aksrt')
|
||||
: ($this->message_filter_args($filters) === [] ? __('站内还没有任何通知。', 'aksrt') : __('当前筛选条件下没有消息。', 'aksrt'));
|
||||
printf(
|
||||
'<tr class="no-items"><td colspan="6"><div class="aksrt-messages-admin__empty"><span class="dashicons dashicons-email-alt" aria-hidden="true"></span><strong>%s</strong><span>%s</span></div></td></tr>',
|
||||
esc_html__('暂无消息', 'aksrt'),
|
||||
esc_html($hint)
|
||||
);
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_message_row($row, $filters);
|
||||
}
|
||||
echo '</tbody></table></form>';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function render_message_row(array $row, array $filters): void {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$title = (string) ($row['title'] ?? '');
|
||||
$desc = (string) ($row['description'] ?? '');
|
||||
$url = (string) ($row['target_url'] ?? '');
|
||||
$unread = empty($row['is_read']);
|
||||
|
||||
echo '<tr class="' . ($unread ? 'is-unread' : 'is-read') . '">';
|
||||
printf(
|
||||
'<th scope="row" class="check-column"><label class="screen-reader-text" for="aksrt-msg-%1$d">%2$s</label><input id="aksrt-msg-%1$d" type="checkbox" name="ids[]" value="%1$d" /></th>',
|
||||
$id,
|
||||
esc_html(sprintf(__('选择消息 #%d', 'aksrt'), $id))
|
||||
);
|
||||
|
||||
echo '<td class="column-title"><strong class="row-title">' . esc_html($title) . '</strong>';
|
||||
if ($desc !== '' && $desc !== $title) {
|
||||
echo '<div class="aksrt-messages-admin__desc">' . esc_html(mb_strimwidth($desc, 0, 160, '…')) . '</div>';
|
||||
}
|
||||
echo '<div class="aksrt-messages-admin__sub"><code>#' . esc_html((string) $id) . '</code>';
|
||||
if ($url !== '') {
|
||||
printf(
|
||||
' <a href="%s" target="_blank" rel="noopener noreferrer">%s<span class="screen-reader-text">%s</span></a>',
|
||||
esc_url($url),
|
||||
esc_html__('跳转链接', 'aksrt'),
|
||||
esc_html__('(在新窗口打开)', 'aksrt')
|
||||
);
|
||||
}
|
||||
echo '</div></td>';
|
||||
|
||||
printf(
|
||||
'<td class="column-type"><a class="aksrt-msg-badge is-%s" href="%s">%s</a></td>',
|
||||
esc_attr(MessageTypes::tone($type)),
|
||||
esc_url($this->message_page_url(array_merge($this->message_filter_args($filters), ['type' => $type]))),
|
||||
esc_html(MessageTypes::label($type))
|
||||
);
|
||||
|
||||
echo '<td class="column-user">' . $this->message_user_cell((int) ($row['user_id'] ?? 0), $filters) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in builder.
|
||||
|
||||
$read_at = (string) ($row['read_at'] ?? '');
|
||||
printf(
|
||||
'<td class="column-read"><span class="aksrt-msg-state %s"%s>%s</span></td>',
|
||||
$unread ? 'is-unread' : 'is-read',
|
||||
!$unread && $read_at !== '' ? ' title="' . esc_attr(sprintf(__('阅读于 %s', 'aksrt'), $this->message_time($read_at))) . '"' : '',
|
||||
$unread ? esc_html__('未读', 'aksrt') : esc_html__('已读', 'aksrt')
|
||||
);
|
||||
|
||||
$created = (string) ($row['created_at'] ?? '');
|
||||
$ts = $created !== '' ? strtotime($created . ' UTC') : false;
|
||||
printf(
|
||||
'<td class="column-time"><time datetime="%s">%s</time>%s</td>',
|
||||
esc_attr($ts ? gmdate('c', $ts) : ''),
|
||||
esc_html($this->message_time($created)),
|
||||
$ts ? '<div class="aksrt-messages-admin__sub">' . esc_html(sprintf(__('%s前', 'aksrt'), human_time_diff($ts))) . '</div>' : ''
|
||||
);
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
private function message_user_cell(int $user_id, array $filters): string {
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : false;
|
||||
if (!$user) {
|
||||
return '<span class="aksrt-messages-admin__muted">' . esc_html(sprintf(__('已删除用户 #%d', 'aksrt'), $user_id)) . '</span>';
|
||||
}
|
||||
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : get_avatar_url($user_id, ['size' => 48]);
|
||||
$filter_url = $this->message_page_url(array_merge($this->message_filter_args($filters), ['user' => (string) $user_id]));
|
||||
return '<a class="aksrt-msg-user" href="' . esc_url($filter_url) . '" title="' . esc_attr__('只看该用户', 'aksrt') . '">'
|
||||
. '<img src="' . esc_url($avatar) . '" width="24" height="24" alt="" loading="lazy" />'
|
||||
. '<span>' . esc_html($user->display_name) . '</span></a>'
|
||||
. '<div class="aksrt-messages-admin__sub">#' . esc_html((string) $user_id) . ' · <a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html__('编辑', 'aksrt') . '</a></div>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 参数与 URL
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/** @return array{status:string,type:string,user:string,s:string} */
|
||||
private function message_filters(?array $source = null): array {
|
||||
$source = $source ?? $_GET;
|
||||
$status = sanitize_key((string) wp_unslash($source['status'] ?? ''));
|
||||
return [
|
||||
'status' => in_array($status, NotificationAdminQuery::STATUSES, true) ? $status : '',
|
||||
'type' => sanitize_key((string) wp_unslash($source['type'] ?? '')),
|
||||
'user' => trim(sanitize_text_field((string) wp_unslash($source['user'] ?? ''))),
|
||||
's' => trim(sanitize_text_field((string) wp_unslash($source['s'] ?? ''))),
|
||||
];
|
||||
}
|
||||
|
||||
/** 收件人筛选解析为 user_id:0=不限,-1=未找到(查询结果为空)。 */
|
||||
private function message_query_filters(array $filters): array {
|
||||
$user_id = 0;
|
||||
$needle = $filters['user'];
|
||||
if ($needle !== '') {
|
||||
if (ctype_digit($needle)) {
|
||||
$user = get_userdata((int) $needle);
|
||||
} elseif (is_email($needle)) {
|
||||
$user = get_user_by('email', $needle);
|
||||
} else {
|
||||
$user = get_user_by('login', sanitize_user($needle));
|
||||
}
|
||||
$user_id = $user instanceof \WP_User ? (int) $user->ID : -1;
|
||||
}
|
||||
return [
|
||||
'status' => $filters['status'],
|
||||
'type' => $filters['type'],
|
||||
'user_id' => $user_id,
|
||||
's' => $filters['s'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> 非空筛选参数(用于 URL / 隐藏域) */
|
||||
private function message_filter_args(array $filters): array {
|
||||
return array_filter($filters, static fn($value): bool => $value !== '');
|
||||
}
|
||||
|
||||
private function message_page_url(array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => self::MESSAGE_PAGE_SLUG], $args), admin_url('users.php'));
|
||||
}
|
||||
|
||||
/** 通知表时间统一存 GMT,按站点时区展示。 */
|
||||
private function message_time(string $gmt): string {
|
||||
return $gmt !== '' ? get_date_from_gmt($gmt, 'Y-m-d H:i') : '';
|
||||
}
|
||||
|
||||
private function message_fail(string $error): void {
|
||||
$this->message_redirect(['aksrt_msg' => 'send_failed', 'aksrt_msg_error' => $error]);
|
||||
}
|
||||
|
||||
private function message_redirect(array $args): void {
|
||||
wp_safe_redirect($this->message_page_url($args));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* 后台代发系统消息:收件人解析(用户列表 / 角色)+ 逐人投递。
|
||||
*
|
||||
* 逐人走 Message::send(),因此每位收件人都会触发 aksrt_notification_sent
|
||||
* (邮件 / 公众号提醒按通知设置执行),收件人上限即为该扇出的上限。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Messages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AdminBroadcast {
|
||||
|
||||
public const TYPE = 'system';
|
||||
public const MAX_USERS = 100;
|
||||
public const MAX_ROLE_USERS = 500;
|
||||
public const TITLE_MAX = 191;
|
||||
public const CONTENT_MAX = 2000;
|
||||
|
||||
/**
|
||||
* 解析「用户 ID / 用户名 / 邮箱」列表(逗号、分号、空白或换行分隔)。
|
||||
*
|
||||
* @return array{ids:list<int>,unknown:list<string>}|\WP_Error
|
||||
*/
|
||||
public static function resolve_users(string $raw) {
|
||||
$tokens = preg_split('/[\s,,;;]+/u', trim($raw), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$tokens = array_values(array_unique($tokens));
|
||||
if ($tokens === []) {
|
||||
return new \WP_Error('empty', __('请填写收件人。', 'aksrt'));
|
||||
}
|
||||
if (count($tokens) > self::MAX_USERS) {
|
||||
return new \WP_Error('too_many', sprintf(__('单次最多指定 %d 位用户。', 'aksrt'), self::MAX_USERS));
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
$unknown = [];
|
||||
foreach ($tokens as $token) {
|
||||
if (ctype_digit($token)) {
|
||||
$user = get_userdata((int) $token);
|
||||
} elseif (is_email($token)) {
|
||||
$user = get_user_by('email', $token);
|
||||
} else {
|
||||
$user = get_user_by('login', sanitize_user($token));
|
||||
}
|
||||
if ($user instanceof \WP_User) {
|
||||
$ids[] = (int) $user->ID;
|
||||
} else {
|
||||
$unknown[] = $token;
|
||||
}
|
||||
}
|
||||
return ['ids' => array_values(array_unique($ids)), 'unknown' => $unknown];
|
||||
}
|
||||
|
||||
/** @return list<int>|\WP_Error */
|
||||
public static function resolve_role(string $role) {
|
||||
if ($role === '' || !isset(wp_roles()->roles[$role])) {
|
||||
return new \WP_Error('bad_role', __('请选择有效的用户角色。', 'aksrt'));
|
||||
}
|
||||
$ids = array_map('intval', get_users([
|
||||
'role' => $role,
|
||||
'fields' => 'ID',
|
||||
'number' => self::MAX_ROLE_USERS + 1,
|
||||
'count_total' => false,
|
||||
]));
|
||||
if ($ids === []) {
|
||||
return new \WP_Error('empty_role', __('该角色下没有用户。', 'aksrt'));
|
||||
}
|
||||
if (count($ids) > self::MAX_ROLE_USERS) {
|
||||
return new \WP_Error('role_too_large', sprintf(__('该角色用户超过 %d 人,请改用更精确的收件人。', 'aksrt'), self::MAX_ROLE_USERS));
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $user_ids
|
||||
* @return array{sent:int,failed:int}
|
||||
*/
|
||||
public static function send(array $user_ids, string $title, string $content, string $target_url = ''): array {
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
$message = Message::get_instance();
|
||||
foreach ($user_ids as $user_id) {
|
||||
$ok = $message->send((int) $user_id, self::TYPE, $content, [
|
||||
'title' => $title,
|
||||
'target_url' => $target_url,
|
||||
]);
|
||||
$ok ? $sent++ : $failed++;
|
||||
}
|
||||
return ['sent' => $sent, 'failed' => $failed];
|
||||
}
|
||||
}
|
||||
@@ -147,13 +147,6 @@ class Message {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知收件箱完整视图(供 page-messages.php 使用)
|
||||
*/
|
||||
public function get_all_notifications(int $user_id): array {
|
||||
return $this->get_notifications($user_id, ['limit' => 200]);
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
if (class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return \AKSRT\Core\DBMigrator::table('notification');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* 站内通知类型的后台展示口径:中文标签 + 徽标色调。
|
||||
*
|
||||
* 前台消息中心只按「文章评论 / 点赞喜欢 / 系统通知」三类归档(inc/modules/message-center.php),
|
||||
* 后台运营需要看到原始类型,因此这里维护一份细粒度标签。未登记的类型原样显示键名。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Messages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class MessageTypes {
|
||||
|
||||
/** @return array<string,string> */
|
||||
public static function labels(): array {
|
||||
return [
|
||||
'system' => __('系统通知', 'aksrt'),
|
||||
'notice' => __('通知', 'aksrt'),
|
||||
'comment' => __('文章评论', 'aksrt'),
|
||||
'comment_reply' => __('评论回复', 'aksrt'),
|
||||
'comment_approved' => __('评论审核', 'aksrt'),
|
||||
'pending_comments' => __('待审核评论', 'aksrt'),
|
||||
'comment_like' => __('评论点赞', 'aksrt'),
|
||||
'post_like' => __('文章点赞', 'aksrt'),
|
||||
'post_favorite' => __('文章收藏', 'aksrt'),
|
||||
'followed' => __('新增关注', 'aksrt'),
|
||||
'download_ready' => __('下载就绪', 'aksrt'),
|
||||
'level_up' => __('等级提升', 'aksrt'),
|
||||
'medal' => __('徽章授予', 'aksrt'),
|
||||
'auth_apply_reply' => __('认证审核', 'aksrt'),
|
||||
'ban_appeal' => __('禁封申诉', 'aksrt'),
|
||||
'report' => __('举报', 'aksrt'),
|
||||
'withdraw' => __('提现', 'aksrt'),
|
||||
'ticket_new' => __('新工单', 'aksrt'),
|
||||
'ticket_reply' => __('工单回复', 'aksrt'),
|
||||
'ticket_urge' => __('工单催单', 'aksrt'),
|
||||
'ticket_closed' => __('工单关闭', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function label(string $type): string {
|
||||
return self::labels()[$type] ?? $type;
|
||||
}
|
||||
|
||||
/** 徽标色调:neutral / info / success / danger / social。 */
|
||||
public static function tone(string $type): string {
|
||||
if (str_starts_with($type, 'ticket_')) {
|
||||
return 'info';
|
||||
}
|
||||
if (in_array($type, ['report', 'ban_appeal'], true)) {
|
||||
return 'danger';
|
||||
}
|
||||
if (in_array($type, ['withdraw', 'level_up', 'medal', 'auth_apply_reply', 'download_ready'], true)) {
|
||||
return 'success';
|
||||
}
|
||||
if (in_array($type, ['system', 'notice'], true)) {
|
||||
return 'neutral';
|
||||
}
|
||||
return 'social';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* 后台「消息管理」页的通知表查询:分页列表、计数、类型枚举与批量写操作。
|
||||
*
|
||||
* 全站视角(不按当前用户过滤),仅供 manage_options 的原生后台页使用;
|
||||
* 用户自身收件箱读写仍走 Message / inc/modules/user-notifications.php。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Messages;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class NotificationAdminQuery {
|
||||
|
||||
public const PER_PAGE = 20;
|
||||
public const MAX_BULK = 200;
|
||||
public const STATUSES = ['unread', 'read'];
|
||||
|
||||
private const COLUMNS = 'id, user_id, type, title, description, target_url, is_read, created_at, read_at';
|
||||
|
||||
public static function table(): string {
|
||||
return class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('notification') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status:string,type:string,user_id:int,s:string} $filters user_id:0=不限,<0=无匹配用户
|
||||
* @return array{items:list<array<string,mixed>>,total:int,page:int}
|
||||
*/
|
||||
public static function paginate(array $filters, int $page, int $per_page = self::PER_PAGE): array {
|
||||
$table = self::table();
|
||||
if ($table === '') {
|
||||
return ['items' => [], 'total' => 0, 'page' => 1];
|
||||
}
|
||||
global $wpdb;
|
||||
[$where, $params] = self::where($filters, true);
|
||||
|
||||
$total = (int) $wpdb->get_var(self::prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}", $params));
|
||||
// 先夹页码再查数据:越界页(如删光最后一页)回落到末页而不是空表。
|
||||
$page = min(max(1, $page), max(1, (int) ceil($total / $per_page)));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
'SELECT ' . self::COLUMNS . " FROM {$table} WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d",
|
||||
array_merge($params, [$per_page, ($page - 1) * $per_page])
|
||||
), ARRAY_A);
|
||||
|
||||
return ['items' => is_array($rows) ? $rows : [], 'total' => $total, 'page' => $page];
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态视图计数(忽略状态筛选,保留其它筛选)。
|
||||
*
|
||||
* @return array{all:int,unread:int,read:int}
|
||||
*/
|
||||
public static function status_counts(array $filters): array {
|
||||
$table = self::table();
|
||||
if ($table === '') {
|
||||
return ['all' => 0, 'unread' => 0, 'read' => 0];
|
||||
}
|
||||
global $wpdb;
|
||||
[$where, $params] = self::where($filters, false);
|
||||
$row = $wpdb->get_row(self::prepare(
|
||||
"SELECT COUNT(*) AS total, COALESCE(SUM(is_read = 0), 0) AS unread FROM {$table} WHERE {$where}",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
$all = (int) ($row['total'] ?? 0);
|
||||
$unread = (int) ($row['unread'] ?? 0);
|
||||
return ['all' => $all, 'unread' => $unread, 'read' => max(0, $all - $unread)];
|
||||
}
|
||||
|
||||
/** @return array{total:int,unread:int,today:int,recipients:int} */
|
||||
public static function overview(): array {
|
||||
$table = self::table();
|
||||
if ($table === '') {
|
||||
return ['total' => 0, 'unread' => 0, 'today' => 0, 'recipients' => 0];
|
||||
}
|
||||
global $wpdb;
|
||||
// created_at 存 GMT;「今日」按站点时区零点换算。
|
||||
$today_gmt = get_gmt_from_date(wp_date('Y-m-d 00:00:00'));
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(is_read = 0), 0) AS unread,
|
||||
COALESCE(SUM(created_at >= %s), 0) AS today,
|
||||
COUNT(DISTINCT user_id) AS recipients
|
||||
FROM {$table}",
|
||||
$today_gmt
|
||||
), ARRAY_A);
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'unread' => (int) ($row['unread'] ?? 0),
|
||||
'today' => (int) ($row['today'] ?? 0),
|
||||
'recipients' => (int) ($row['recipients'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> 表中实际出现过的类型 */
|
||||
public static function types(): array {
|
||||
$table = self::table();
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
global $wpdb;
|
||||
$types = $wpdb->get_col("SELECT DISTINCT type FROM {$table} ORDER BY type ASC LIMIT 100");
|
||||
return array_values(array_filter(array_map('strval', (array) $types)));
|
||||
}
|
||||
|
||||
/** @return int 实际删除条数 */
|
||||
public static function delete(array $ids): int {
|
||||
$ids = self::ids($ids);
|
||||
$table = self::table();
|
||||
if ($ids === [] || $table === '') {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$in = implode(',', array_fill(0, count($ids), '%d'));
|
||||
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE id IN ({$in})", $ids));
|
||||
}
|
||||
|
||||
/** @return int 由未读变为已读的条数 */
|
||||
public static function mark_read(array $ids): int {
|
||||
$ids = self::ids($ids);
|
||||
$table = self::table();
|
||||
if ($ids === [] || $table === '') {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$in = implode(',', array_fill(0, count($ids), '%d'));
|
||||
return (int) $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET is_read = 1, read_at = %s WHERE is_read = 0 AND id IN ({$in})",
|
||||
array_merge([current_time('mysql', true)], $ids)
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:list<mixed>} */
|
||||
private static function where(array $filters, bool $with_status): array {
|
||||
global $wpdb;
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
$status = (string) ($filters['status'] ?? '');
|
||||
if ($with_status && in_array($status, self::STATUSES, true)) {
|
||||
$where[] = $status === 'unread' ? 'is_read = 0' : 'is_read = 1';
|
||||
}
|
||||
$type = (string) ($filters['type'] ?? '');
|
||||
if ($type !== '') {
|
||||
$where[] = 'type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
$user_id = (int) ($filters['user_id'] ?? 0);
|
||||
if ($user_id !== 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
$s = (string) ($filters['s'] ?? '');
|
||||
if ($s !== '') {
|
||||
$like = '%' . $wpdb->esc_like($s) . '%';
|
||||
$where[] = '(title LIKE %s OR description LIKE %s)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
return [implode(' AND ', $where), $params];
|
||||
}
|
||||
|
||||
private static function prepare(string $sql, array $params): string {
|
||||
global $wpdb;
|
||||
return $params === [] ? $sql : (string) $wpdb->prepare($sql, $params);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function ids(array $ids): array {
|
||||
$ids = array_values(array_unique(array_filter(array_map('absint', $ids))));
|
||||
return array_slice($ids, 0, self::MAX_BULK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 消息管理后台(users.php → aksrt_messages)契约:
|
||||
* 1) 页面由独立 trait 承载,UsersMenus 只做组合与路由注册;
|
||||
* 2) 写操作走 admin_post + nonce + 能力校验;
|
||||
* 3) SQL 全部在 NotificationAdminQuery 内且经 prepare;
|
||||
* 4) 分页用共享 AdminListPagination;
|
||||
* 5) 旧实现与死代码已删除。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('messages page is composed from its own trait', () => {
|
||||
const menus = read('src/Admin/Native/UsersMenus.php');
|
||||
assert.ok(menus.includes('use UsersMenusMessages;'));
|
||||
assert.ok(menus.includes("'aksrt_messages'"));
|
||||
assert.ok(menus.includes("add_action('admin_post_aksrt_users_send_message', [$this, 'handle_send_message'])"));
|
||||
assert.ok(menus.includes("add_action('admin_post_aksrt_users_bulk_messages', [$this, 'handle_bulk_messages'])"));
|
||||
assert.ok(menus.includes("add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets'])"));
|
||||
// 旧实现不得残留在壳类中。
|
||||
assert.doesNotMatch(menus, /function render_messages_page|function handle_send_message|message_sent|message_failed/);
|
||||
assert.doesNotMatch(menus, /aksrt_notification/);
|
||||
});
|
||||
|
||||
test('write handlers check capability and nonce', () => {
|
||||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||||
assert.match(page, /function handle_send_message\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_send_message'\);/);
|
||||
assert.match(page, /function handle_bulk_messages\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_bulk_messages'\);/);
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||||
// 页面层不直接碰数据库,分页走共享组件。
|
||||
assert.doesNotMatch(page, /\$wpdb/);
|
||||
assert.ok(page.includes('AdminListPagination::render('));
|
||||
});
|
||||
|
||||
test('admin query keeps SQL prepared and bulk size capped', () => {
|
||||
const query = read('src/Messages/NotificationAdminQuery.php');
|
||||
assert.match(query, /DELETE FROM \{\$table\} WHERE id IN \(\{\$in\}\)", \$ids\)/);
|
||||
assert.ok(query.includes("array_fill(0, count($ids), '%d')"));
|
||||
assert.ok(query.includes('array_slice($ids, 0, self::MAX_BULK)'));
|
||||
assert.ok(query.includes('$wpdb->esc_like($s)'));
|
||||
});
|
||||
|
||||
test('dead notification helpers are removed', () => {
|
||||
assert.doesNotMatch(read('inc/modules/user-notifications.php'), /aksrt_notification_type_label/);
|
||||
assert.doesNotMatch(read('src/Messages/Message.php'), /get_all_notifications/);
|
||||
assert.ok(fs.existsSync(path.join(root, 'assets/css/admin/messages.css')));
|
||||
});
|
||||
Reference in new issue
Block a user