diff --git a/assets/css/admin/messages.css b/assets/css/admin/messages.css new file mode 100644 index 00000000..9f6b89e1 --- /dev/null +++ b/assets/css/admin/messages.css @@ -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; } +} diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php index f6d8590e..fcdadce5 100644 --- a/inc/modules/user-notifications.php +++ b/inc/modules/user-notifications.php @@ -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 { diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 120e6fcf..1f9d2840 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -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 '

' . esc_html__('消息管理', 'aksrt') . '

'; - $this->flash(); - - echo '

' . esc_html__('发送系统消息', 'aksrt') . '

'; - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_send_message'); - echo '

'; - echo '

'; - echo '

'; - submit_button(__('发送', 'aksrt')); - echo '
'; - - $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 '

' . esc_html__('通知表不可用。', 'aksrt') . '

'; - 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 '

' . esc_html__('最近消息', 'aksrt') . '

'; - echo ''; - echo ''; - echo ''; - if (!$items) { - echo ''; - } - foreach ((array) $items as $row) { - $user_id = (int) ($row['user_id'] ?? 0); - $user = $user_id > 0 ? get_userdata($user_id) : null; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
ID' . esc_html__('用户', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('标题', 'aksrt') . '' . esc_html__('已读', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '
' . esc_html__('暂无消息', 'aksrt') . '
' . esc_html((string) ($row['id'] ?? '')) . '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '' . esc_html((string) ($row['type'] ?? '')) . '' . esc_html((string) ($row['title'] ?? $row['description'] ?? '')) . '' . (!empty($row['is_read']) ? esc_html__('是', 'aksrt') : esc_html__('否', 'aksrt')) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
'; - $this->pagination($pages, $page_num); - echo ''; - } - - 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 * ----------------------------------------------------------------*/ diff --git a/src/Admin/Native/UsersMenusMessages.php b/src/Admin/Native/UsersMenusMessages.php new file mode 100644 index 00000000..b8ff69eb --- /dev/null +++ b/src/Admin/Native/UsersMenusMessages.php @@ -0,0 +1,516 @@ +ensure_cap(); + $filters = $this->message_filters(); + $paged = max(1, absint($_GET['paged'] ?? 1)); + + echo '
'; + echo '

' . esc_html__('消息管理', 'aksrt') . '

'; + printf( + '%s', + esc_url(admin_url('admin.php?page=aksrt-settings#/users/notifications')), + esc_html__('通知配置', 'aksrt') + ); + echo '
'; + $this->render_message_notices(); + + if (NotificationAdminQuery::table() === '') { + echo '

' . esc_html__('通知表不可用。', 'aksrt') . '

'; + 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 ''; + } + + /* ------------------------------------------------------------------ + * 写操作 + * ----------------------------------------------------------------*/ + + 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( + '

%s

', + $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 ''; + } + + private function render_message_compose(): void { + $open = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? '')) === 'send_failed'; + + echo '
'; + echo '' . esc_html__('发送系统消息', 'aksrt') . ''; + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_send_message'); + + echo '
' . esc_html__('收件人', 'aksrt') . ''; + printf( + '', + esc_html__('指定用户', 'aksrt') + ); + printf( + '', + esc_html__('按角色', 'aksrt') + ); + echo '
'; + + echo '
'; + printf( + '', + esc_html__('用户', 'aksrt'), + esc_attr__('用户 ID / 用户名 / 邮箱,逗号或换行分隔', 'aksrt'), + esc_html(sprintf(__('单次最多 %d 位。', 'aksrt'), AdminBroadcast::MAX_USERS)) + ); + echo ''; + printf( + '', + esc_html__('标题', 'aksrt'), + (int) AdminBroadcast::TITLE_MAX, + esc_attr__('留空则取内容开头', 'aksrt') + ); + printf( + '', + esc_html__('跳转链接(可选)', 'aksrt') + ); + printf( + '', + esc_html__('内容', 'aksrt'), + (int) AdminBroadcast::CONTENT_MAX + ); + echo '
'; + printf( + '

%s

', + esc_html__('以「系统通知」类型逐人投递;邮件 / 公众号提醒按通知配置同步触发。', 'aksrt'), + esc_html__('发送', 'aksrt') + ); + echo '
'; + } + + /** @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 ''; + } + + private function render_message_filter_form(array $filters): void { + echo '
'; + echo ''; + if ($filters['status'] !== '') { + echo ''; + } + echo ''; + echo ''; + printf( + '', + esc_html__('按收件人筛选', 'aksrt'), + esc_attr($filters['user']), + esc_attr__('收件人 ID / 用户名 / 邮箱', 'aksrt') + ); + printf( + '', + esc_html__('搜索消息', 'aksrt'), + esc_attr($filters['s']), + esc_attr__('标题 / 内容', 'aksrt') + ); + submit_button(__('筛选', 'aksrt'), '', '', false); + if ($filters['type'] !== '' || $filters['user'] !== '' || $filters['s'] !== '') { + printf( + '%s', + esc_url($this->message_page_url($filters['status'] === '' ? [] : ['status' => $filters['status']])), + esc_html__('清除筛选', 'aksrt') + ); + } + echo '
'; + } + + /** @param list> $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 '
'; + echo ''; + wp_nonce_field('aksrt_users_bulk_messages'); + foreach ($this->message_filter_args($filters) as $key => $value) { + printf('', esc_attr($key), esc_attr((string) $value)); + } + echo ''; + + echo '
'; + printf( + ' ', + esc_html__('标记已读', 'aksrt') + ); + printf( + '', + esc_js(__('确定删除所选消息?删除后用户收件箱中也将不再显示。', 'aksrt')), + esc_html__('删除', 'aksrt') + ); + echo '

'; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + if ($items === []) { + $hint = $user_missing + ? __('没有找到该收件人。', 'aksrt') + : ($this->message_filter_args($filters) === [] ? __('站内还没有任何通知。', 'aksrt') : __('当前筛选条件下没有消息。', 'aksrt')); + printf( + '', + esc_html__('暂无消息', 'aksrt'), + esc_html($hint) + ); + } + foreach ($items as $row) { + $this->render_message_row($row, $filters); + } + echo '
' . esc_html__('消息', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('收件人', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('发送时间', 'aksrt') . '
%s%s
'; + } + + /** @param array $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 ''; + printf( + '', + $id, + esc_html(sprintf(__('选择消息 #%d', 'aksrt'), $id)) + ); + + echo '' . esc_html($title) . ''; + if ($desc !== '' && $desc !== $title) { + echo '
' . esc_html(mb_strimwidth($desc, 0, 160, '…')) . '
'; + } + echo '
#' . esc_html((string) $id) . ''; + if ($url !== '') { + printf( + ' %s%s', + esc_url($url), + esc_html__('跳转链接', 'aksrt'), + esc_html__('(在新窗口打开)', 'aksrt') + ); + } + echo '
'; + + printf( + '%s', + 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 '' . $this->message_user_cell((int) ($row['user_id'] ?? 0), $filters) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in builder. + + $read_at = (string) ($row['read_at'] ?? ''); + printf( + '%s', + $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( + '%s', + esc_attr($ts ? gmdate('c', $ts) : ''), + esc_html($this->message_time($created)), + $ts ? '
' . esc_html(sprintf(__('%s前', 'aksrt'), human_time_diff($ts))) . '
' : '' + ); + echo ''; + } + + private function message_user_cell(int $user_id, array $filters): string { + $user = $user_id > 0 ? get_userdata($user_id) : false; + if (!$user) { + return '' . esc_html(sprintf(__('已删除用户 #%d', 'aksrt'), $user_id)) . ''; + } + $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 '' + . '' + . '' . esc_html($user->display_name) . '' + . '
#' . esc_html((string) $user_id) . ' · ' . esc_html__('编辑', 'aksrt') . '
'; + } + + /* ------------------------------------------------------------------ + * 参数与 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 非空筛选参数(用于 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; + } +} diff --git a/src/Messages/AdminBroadcast.php b/src/Messages/AdminBroadcast.php new file mode 100644 index 00000000..52cf1fea --- /dev/null +++ b/src/Messages/AdminBroadcast.php @@ -0,0 +1,94 @@ +,unknown:list}|\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|\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 $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]; + } +} diff --git a/src/Messages/Message.php b/src/Messages/Message.php index e580aabf..dc8f8cf1 100644 --- a/src/Messages/Message.php +++ b/src/Messages/Message.php @@ -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'); diff --git a/src/Messages/MessageTypes.php b/src/Messages/MessageTypes.php new file mode 100644 index 00000000..b5c301bb --- /dev/null +++ b/src/Messages/MessageTypes.php @@ -0,0 +1,64 @@ + */ + 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'; + } +} diff --git a/src/Messages/NotificationAdminQuery.php b/src/Messages/NotificationAdminQuery.php new file mode 100644 index 00000000..0a531e68 --- /dev/null +++ b/src/Messages/NotificationAdminQuery.php @@ -0,0 +1,174 @@ +>,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 表中实际出现过的类型 */ + 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} */ + 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 */ + 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); + } +} diff --git a/tests/widget-system/message-admin-contract.test.cjs b/tests/widget-system/message-admin-contract.test.cjs new file mode 100644 index 00000000..dd24cbe5 --- /dev/null +++ b/tests/widget-system/message-admin-contract.test.cjs @@ -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'))); +});