Add message details, per-row actions, selection-aware bulk controls and confirmation dialogs. Merge filters into the list card and link overview statistics to the matching views with site-timezone today filtering. Improve the compose dialog with Unicode character counters, recipient mode segments, role counts and explicit broadcast confirmation verified against the resolved role and recipient count on submission. Split rendering into focused sibling traits and cover rendering, handlers and UI interactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
7.2 KiB
PHP
189 lines
7.2 KiB
PHP
<?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,period?: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_start, $today_end] = self::today_range();
|
||
$row = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT COUNT(*) AS total,
|
||
COALESCE(SUM(is_read = 0), 0) AS unread,
|
||
COALESCE(SUM(created_at >= %s AND created_at < %s), 0) AS today,
|
||
COUNT(DISTINCT user_id) AS recipients
|
||
FROM {$table}",
|
||
$today_start,
|
||
$today_end
|
||
), 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;
|
||
}
|
||
if (($filters['period'] ?? '') === 'today') {
|
||
$where[] = 'created_at >= %s AND created_at < %s';
|
||
array_push($params, ...self::today_range());
|
||
}
|
||
return [implode(' AND ', $where), $params];
|
||
}
|
||
|
||
/** 先在站点时区计算自然日,再转换成 GMT;正确处理夏令时的 23 / 25 小时日。 */
|
||
private static function today_range(): array {
|
||
$start = current_datetime()->setTime(0, 0, 0);
|
||
return [
|
||
get_gmt_from_date($start->format('Y-m-d H:i:s')),
|
||
get_gmt_from_date($start->modify('+1 day')->format('Y-m-d H:i:s')),
|
||
];
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|