From 800baca0cafe99cb3964a5cbc99a2f2a66e5e747 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 24 Sep 2026 18:29:48 +0800 Subject: [PATCH] feat(admin): rework download logs page columns and selection - Headers are now checkbox / index / post / user / credits cost / IP / time; drop the source index, usage and expiry columns - Add a check-column before the index: header select-all for multi-select plus per-row checkboxes, tokens as row values - Bulk delete selected tokens behind nonce and capability checks with prepared statements, redirect-after-post back to the filtered view - Index numbers continue across pagination; dead comments removed --- src/Admin/Native/PayDownloadLogs.php | 146 +++++++++++++++++++++------ 1 file changed, 115 insertions(+), 31 deletions(-) diff --git a/src/Admin/Native/PayDownloadLogs.php b/src/Admin/Native/PayDownloadLogs.php index 77f42357..a79d7cb9 100644 --- a/src/Admin/Native/PayDownloadLogs.php +++ b/src/Admin/Native/PayDownloadLogs.php @@ -11,6 +11,10 @@ class PayDownloadLogs { public const CAPABILITY = 'manage_options'; public const PAGE_SLUG = 'aksrt_download_logs'; + public const NONCE = 'aksrt_download_logs_bulk'; + + /** 单次批量删除的令牌上限,与列表每页条数量级一致,防止超大 IN 查询。 */ + private const BULK_DELETE_LIMIT = 100; private static $instance = null; @@ -22,13 +26,28 @@ class PayDownloadLogs { } public function init(): void { - // Read-only. + // 页面动作(批量删除)在 render_page() 渲染前就地处理,无需额外钩子。 } public function render_page(): void { $this->ensure_cap(); + + if ($this->is_bulk_delete_request()) { + $this->handle_bulk_delete(); + } + echo '

' . esc_html__('下载记录', 'aksrt') . '

'; + if (isset($_GET['dl_deleted'])) { + echo '

' . esc_html(sprintf( + /* translators: %d: deleted record count */ + __('已删除 %d 条下载记录。', 'aksrt'), + max(0, (int) $_GET['dl_deleted']) + )) . '

'; + } elseif (isset($_GET['dl_empty'])) { + echo '

' . esc_html__('未选择需要删除的记录。', 'aksrt') . '

'; + } + if (!class_exists('\AKSRT\Core\DBMigrator')) { echo '

' . esc_html__('数据库服务不可用。', 'aksrt') . '

'; return; @@ -64,55 +83,52 @@ class PayDownloadLogs { echo ' '; echo ' '; echo ' '; - // When unchecked, pass used=0 explicitly. - if (!$only_used) { - // no-op; checkbox not checked - } submit_button(__('筛选', 'aksrt'), 'secondary', '', false); echo ' ' . esc_html__('重置', 'aksrt') . ''; echo ''; - // Fix: unchecked checkbox means used filter off — re-read without default if form submitted with no used. - // Already handled via isset($_GET['used']). + // 批量操作(多选 / 单选)包裹列表;删除成功后重定向回当前筛选视图。 + echo '
'; + wp_nonce_field(self::NONCE); + echo '
'; + echo ''; + echo ''; + echo ''; + echo '

'; echo ''; echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo ''; - echo ''; - echo ''; echo ''; + echo ''; echo ''; if (!$items) { - echo ''; + echo ''; } - foreach ($items as $row) { - $uid = (int) ($row['user_id'] ?? 0); - $pid = (int) ($row['post_id'] ?? 0); - $user = $uid > 0 ? get_userdata($uid) : null; + $seq_offset = ($page_num - 1) * $per_page; + foreach ($items as $i => $row) { + $uid = (int) ($row['user_id'] ?? 0); + $pid = (int) ($row['post_id'] ?? 0); + $token = (string) ($row['token'] ?? ''); + $user = $uid > 0 ? get_userdata($uid) : null; $title = $pid > 0 ? get_the_title($pid) : ''; if ($title === '') { $title = $pid > 0 ? sprintf(__('(已删除 #%d)', 'aksrt'), $pid) : '—'; } $edit = $pid > 0 ? get_edit_post_link($pid, 'raw') : ''; - $used = (int) ($row['used_count'] ?? 0); - $max = (int) ($row['max_uses'] ?? 1); echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo ''; - echo ''; - echo ''; echo ''; + echo ''; echo ''; } echo '
' . esc_html__('时间', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('序号', 'aksrt') . '' . esc_html__('文章', 'aksrt') . '' . esc_html__('源序号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('积分消耗', 'aksrt') . '' . esc_html__('使用', 'aksrt') . '' . esc_html__('过期', 'aksrt') . '' . esc_html__('IP', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '
' . esc_html__('暂无下载记录。', 'aksrt') . '
' . esc_html__('暂无下载记录。', 'aksrt') . '
' . esc_html((string) ($row['created_at'] ?? '')) . ''; - if ($user) { - echo esc_html($user->display_name . ' (#' . $uid . ')'); - echo '
' . esc_html__('仅此用户', 'aksrt') . ''; - } else { - echo esc_html((string) $uid); - } - echo '
' . esc_html((string) ($seq_offset + $i + 1)) . ''; if ($edit) { echo '' . esc_html($title) . ''; @@ -123,14 +139,21 @@ class PayDownloadLogs { echo '
' . esc_html__('仅此文章', 'aksrt') . ''; } echo '
' . esc_html((string) (int) ($row['source_index'] ?? 0)) . ''; + if ($user) { + echo esc_html($user->display_name . ' (#' . $uid . ')'); + echo '
' . esc_html__('仅此用户', 'aksrt') . ''; + } else { + echo esc_html((string) $uid); + } + echo '
' . esc_html((string) (int) ($row['cost'] ?? 0)) . '' . esc_html($used . ' / ' . $max) . ($used > 0 ? ' ' : '') . '' . esc_html((string) ($row['expires_at'] ?? '')) . '' . esc_html((string) ($row['ip_bind'] ?? '')) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
'; + echo '
'; if ($pages > 1) { echo '
'; @@ -147,6 +170,67 @@ class PayDownloadLogs { echo '
'; } + /** + * 当前页面 URL(含筛选参数),用于批量表单回帖与删除后重定向。 + */ + private function current_url(): string { + $args = ['page' => self::PAGE_SLUG]; + foreach (['post_id', 'user_id', 'used', 'paged'] as $key) { + if (isset($_GET[$key])) { + $args[$key] = wp_unslash($_GET[$key]); + } + } + return add_query_arg($args, admin_url('admin.php')); + } + + private function is_bulk_delete_request(): bool { + return (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') + && isset($_POST['bulk_action']) + && ((string) wp_unslash($_POST['bulk_action']) === 'delete'); + } + + /** + * 批量删除选中的下载令牌记录;完成后重定向回当前筛选视图,避免刷新重复提交。 + */ + private function handle_bulk_delete(): void { + check_admin_referer(self::NONCE); + + $raw = isset($_POST['dl_token']) ? (array) wp_unslash($_POST['dl_token']) : []; + $tokens = array_values(array_unique(array_filter($raw, static function ($token): bool { + return is_string($token) && preg_match('/^[A-Za-z0-9]{1,64}$/', $token) === 1; + }))); + $tokens = array_slice($tokens, 0, self::BULK_DELETE_LIMIT); + + if (!$tokens) { + wp_safe_redirect(add_query_arg('dl_empty', '1', $this->current_url())); + exit; + } + + $deleted = $this->delete_tokens($tokens); + wp_safe_redirect(add_query_arg('dl_deleted', $deleted, $this->current_url())); + exit; + } + + /** + * @param array $tokens + */ + private function delete_tokens(array $tokens): int { + global $wpdb; + + if (!class_exists('\AKSRT\Core\DBMigrator')) { + return 0; + } + $table = \AKSRT\Core\DBMigrator::table('dl_token'); + if ($table === '') { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($tokens), '%s')); + $sql = "DELETE FROM {$table} WHERE token IN ({$placeholders})"; + + return (int) $wpdb->query($wpdb->prepare($sql, $tokens)); + } + /** * @param array{post_id?:int,user_id?:int,only_used?:bool,limit?:int,offset?:int} $args * @return array{items: array>, total:int}