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
This commit is contained in:
2026-09-24 18:29:48 +08:00
parent 1c1d3f17b7
commit 800baca0ca
+115 -31
View File
@@ -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 '<div class="wrap"><h1>' . esc_html__('下载记录', 'aksrt') . '</h1>';
if (isset($_GET['dl_deleted'])) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(sprintf(
/* translators: %d: deleted record count */
__('已删除 %d 条下载记录。', 'aksrt'),
max(0, (int) $_GET['dl_deleted'])
)) . '</p></div>';
} elseif (isset($_GET['dl_empty'])) {
echo '<div class="notice notice-error"><p>' . esc_html__('未选择需要删除的记录。', 'aksrt') . '</p></div>';
}
if (!class_exists('\AKSRT\Core\DBMigrator')) {
echo '<div class="notice notice-error"><p>' . esc_html__('数据库服务不可用。', 'aksrt') . '</p></div></div>';
return;
@@ -64,55 +83,52 @@ class PayDownloadLogs {
echo '<label>' . esc_html__('文章 ID', 'aksrt') . ' <input type="number" min="0" name="post_id" value="' . esc_attr($post_id > 0 ? (string) $post_id : '') . '" class="small-text" /></label> ';
echo '<label>' . esc_html__('用户 ID', 'aksrt') . ' <input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" class="small-text" /></label> ';
echo '<label><input type="checkbox" name="used" value="1" ' . checked($only_used, true, false) . ' /> ' . esc_html__('仅已使用', 'aksrt') . '</label> ';
// When unchecked, pass used=0 explicitly.
if (!$only_used) {
// no-op; checkbox not checked
}
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo ' <a class="button" href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG)) . '">' . esc_html__('重置', 'aksrt') . '</a>';
echo '</form>';
// Fix: unchecked checkbox means used filter off — re-read without default if form submitted with no used.
// Already handled via isset($_GET['used']).
// 批量操作(多选 / 单选)包裹列表;删除成功后重定向回当前筛选视图。
echo '<form method="post" action="' . esc_url($this->current_url()) . '">';
wp_nonce_field(self::NONCE);
echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
echo '<label for="bulk-action-selector-top" class="screen-reader-text">' . esc_html__('选择批量操作', 'aksrt') . '</label>';
echo '<select name="bulk_action" id="bulk-action-selector-top">';
echo '<option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option>';
echo '<option value="delete">' . esc_html__('删除', 'aksrt') . '</option>';
echo '</select>';
echo '<input type="submit" class="button action" value="' . esc_attr__('应用', 'aksrt') . '">';
echo '</div><br class="clear"></div>';
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<td id="cb" class="manage-column column-cb check-column" style="width:2%;"><label class="screen-reader-text" for="cb-select-all-1">' . esc_html__('全选', 'aksrt') . '</label><input id="cb-select-all-1" type="checkbox" onclick="document.querySelectorAll(\'tbody [name=&quot;dl_token[]&quot;]\').forEach(c=>c.checked=this.checked)"></td>';
echo '<th>' . esc_html__('序号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('文章', 'aksrt') . '</th>';
echo '<th>' . esc_html__('源序号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('积分消耗', 'aksrt') . '</th>';
echo '<th>' . esc_html__('使用', 'aksrt') . '</th>';
echo '<th>' . esc_html__('过期', 'aksrt') . '</th>';
echo '<th>' . esc_html__('IP', 'aksrt') . '</th>';
echo '<th>' . esc_html__('时间', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo '<tr><td colspan="8">' . esc_html__('暂无下载记录。', 'aksrt') . '</td></tr>';
echo '<tr><td colspan="7">' . esc_html__('暂无下载记录。', 'aksrt') . '</td></tr>';
}
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 '<tr>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '<td>';
if ($user) {
echo esc_html($user->display_name . ' (#' . $uid . ')');
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&user_id=' . $uid)) . '">' . esc_html__('仅此用户', 'aksrt') . '</a>';
} else {
echo esc_html((string) $uid);
}
echo '</td>';
echo '<th scope="row" class="check-column"><label class="screen-reader-text" for="cb-select-' . esc_attr($token) . '">' . esc_html__('选择', 'aksrt') . '</label><input id="cb-select-' . esc_attr($token) . '" type="checkbox" name="dl_token[]" value="' . esc_attr($token) . '"></th>';
echo '<td>' . esc_html((string) ($seq_offset + $i + 1)) . '</td>';
echo '<td>';
if ($edit) {
echo '<a href="' . esc_url($edit) . '">' . esc_html($title) . '</a>';
@@ -123,14 +139,21 @@ class PayDownloadLogs {
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $pid)) . '">' . esc_html__('仅此文章', 'aksrt') . '</a>';
}
echo '</td>';
echo '<td>' . esc_html((string) (int) ($row['source_index'] ?? 0)) . '</td>';
echo '<td>';
if ($user) {
echo esc_html($user->display_name . ' (#' . $uid . ')');
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&user_id=' . $uid)) . '">' . esc_html__('仅此用户', 'aksrt') . '</a>';
} else {
echo esc_html((string) $uid);
}
echo '</td>';
echo '<td>' . esc_html((string) (int) ($row['cost'] ?? 0)) . '</td>';
echo '<td>' . esc_html($used . ' / ' . $max) . ($used > 0 ? ' <span class="dashicons dashicons-yes" style="color:green;"></span>' : '') . '</td>';
echo '<td>' . esc_html((string) ($row['expires_at'] ?? '')) . '</td>';
echo '<td><code>' . esc_html((string) ($row['ip_bind'] ?? '')) . '</code></td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
echo '</form>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">';
@@ -147,6 +170,67 @@ class PayDownloadLogs {
echo '</div>';
}
/**
* 当前页面 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<int,string> $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<int,array<string,mixed>>, total:int}