Files
wordpress-theme-aksrt/src/Admin/Native/UsersMenusMessages.php
T
kate522andClaude Fable 5 6681b0c306 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>
2026-10-27 22:51:11 +08:00

517 lines
25 KiB
PHP

<?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;
}
}