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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user