七个支付中心列表页(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 /
提现审核 / 卡密管理)的分页收敛到共享助手 AdminListPagination,改用 WP 原生
paginate_links() 渲染。
- 基准 URL 显式以 page 参数 + 当前筛选参数拼装,不再让两个参数的
add_query_arg('paged', ...) 退回 $_SERVER['REQUEST_URI']:链接不再随当前
请求形态漂移,列条件也不再丢失
- 售后服务从写死的 limit => 100 改为 limit/offset 分页,总数接上原本零调用的
AfterSaleService::count();after_sale_status 先按状态常量归一,保证 count()
与 admin_list() 口径一致,分页条不说谎
- 每页 20 条,统一来自 AdminListPagination::DEFAULT_PER_PAGE
- 每页在查询前夹取页码,越界时回退到最后一页并重查,杜绝「空表且没有分页条」
的死胡同;页码夹取与早退也一并收敛到助手
- 提现审核的翻页链接排除 retry_id / retry_key 两个一次性动作参数
- 订单明细的标签页与翻页链接共用同一份筛选参数,不再各写一份
清理死代码:五个页面里近乎复制的 render_pagination()(其中订单明细与提现审核
两份从未被调用)、两页重复的内联 paginate_links 块、卡密管理的 render_pagenavi()
及其 docblock、PayChargeCards::DEFAULT_PER_PAGE 常量,以及只服务自定义禁用
按钮的 .tablenav-pages a.disabled 规则。
验证:tests/admin/ 76 项通过(新增 pay-pages-pagination.test.cjs,重写
charge-cards-pagination.test.cjs);用 git show HEAD 取出原文件对比,订单明细
与售后服务两页在新旧实现下渲染输出逐字节相同。
377 lines
15 KiB
PHP
377 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Admin\Native;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class PayProducts {
|
|
|
|
public const CAPABILITY = 'manage_options';
|
|
public const PAGE_SLUG = 'aksrt_pay_products';
|
|
|
|
private static $instance = null;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
public function init(): void {
|
|
// Read-only page; menu callback only.
|
|
}
|
|
|
|
public function render_products_page(): void {
|
|
$this->ensure_cap();
|
|
$this->render_header(__('付费文章', 'aksrt'));
|
|
|
|
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
|
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
|
|
$this->render_footer();
|
|
return;
|
|
}
|
|
|
|
$pay_type = isset($_GET['pay_type']) ? sanitize_key((string) wp_unslash($_GET['pay_type'])) : '';
|
|
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
|
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
|
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
|
|
|
|
$type_labels = \AKSRT\Core\DomainModel::pay_type_labels();
|
|
unset($type_labels[\AKSRT\Core\DomainModel::PAY_TYPE_NO]);
|
|
|
|
$query = $this->query_paid_posts([
|
|
'pay_type' => $pay_type,
|
|
'search' => $search,
|
|
'page' => $page_num,
|
|
'per_page' => $per_page,
|
|
]);
|
|
|
|
$posts = $query['posts'];
|
|
$total = (int) $query['total'];
|
|
$pages = max(1, (int) ceil($total / $per_page));
|
|
|
|
// 页码越界(手工改 URL、或筛选后总数变小)会让 WP_Query 停在空页上:表格空掉
|
|
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
|
|
if ($page_num > $pages) {
|
|
$page_num = $pages;
|
|
$query = $this->query_paid_posts([
|
|
'pay_type' => $pay_type,
|
|
'search' => $search,
|
|
'page' => $page_num,
|
|
'per_page' => $per_page,
|
|
]);
|
|
$posts = $query['posts'];
|
|
}
|
|
|
|
$sales_map = [];
|
|
if ($posts && class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
$ids = array_map(static function ($p) {
|
|
return (int) $p->ID;
|
|
}, $posts);
|
|
$sales_map = \AKSRT\Payment\OrderRepository::get_instance()->get_content_sales_map(
|
|
$ids,
|
|
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
|
);
|
|
}
|
|
|
|
$this->render_filters($pay_type, $search, $type_labels);
|
|
|
|
echo '<table class="wp-list-table widefat fixed striped">';
|
|
echo '<thead><tr>';
|
|
echo '<th style="width:28%;">' . esc_html__('商品', 'aksrt') . '</th>';
|
|
echo '<th>' . esc_html__('ID', '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__('Meta 销量', 'aksrt') . '</th>';
|
|
echo '<th>' . esc_html__('销售总额', 'aksrt') . '</th>';
|
|
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
|
|
echo '</tr></thead><tbody>';
|
|
|
|
if (!$posts) {
|
|
echo '<tr><td colspan="10">' . esc_html__('暂无付费文章。', 'aksrt') . '</td></tr>';
|
|
}
|
|
|
|
foreach ($posts as $post) {
|
|
$post_id = (int) $post->ID;
|
|
$meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : [];
|
|
$pt = \AKSRT\Core\DomainModel::normalize_pay_type((string) ($meta['pay_type'] ?? \AKSRT\Core\DomainModel::PAY_TYPE_NO));
|
|
$pt_label = $type_labels[$pt] ?? $pt;
|
|
$modo = (($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0';
|
|
$modo_label = $modo === 'points' ? __('积分', 'aksrt') : __('现金', 'aksrt');
|
|
|
|
if ($modo === 'points') {
|
|
$list_price = max(0, (int) ($meta['points_price'] ?? 0));
|
|
$price_text = sprintf(__('%d 积分', 'aksrt'), $list_price);
|
|
} else {
|
|
$price_text = $this->cash_decimal((string) ($meta['price'] ?? '0'));
|
|
}
|
|
|
|
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => '0.00', 'revenue' => '0.00'];
|
|
$db_sales = (int) ($sales['sales_count'] ?? 0);
|
|
$avg = $this->cash_decimal((string) ($sales['avg_amount'] ?? '0'));
|
|
$revenue = $this->cash_decimal((string) ($sales['revenue'] ?? '0'));
|
|
$meta_sales = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true);
|
|
|
|
$edit_url = get_edit_post_link($post_id, 'raw');
|
|
$title = get_the_title($post_id);
|
|
if ($title === '') {
|
|
$title = sprintf(__('(无标题 #%d)', 'aksrt'), $post_id);
|
|
}
|
|
|
|
$orders_url = $this->page_url('aksrt_orders', [
|
|
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
|
'product_id' => $post_id,
|
|
]);
|
|
|
|
echo '<tr>';
|
|
echo '<td>';
|
|
if ($edit_url) {
|
|
echo '<a href="' . esc_url($edit_url) . '"><strong>' . esc_html($title) . '</strong></a>';
|
|
} else {
|
|
echo '<strong>' . esc_html($title) . '</strong>';
|
|
}
|
|
echo '</td>';
|
|
echo '<td>' . esc_html((string) $post_id) . '</td>';
|
|
echo '<td>' . esc_html((string) $pt_label) . '</td>';
|
|
echo '<td>' . esc_html($modo_label) . '</td>';
|
|
echo '<td>' . esc_html($price_text) . '</td>';
|
|
echo '<td>' . esc_html($avg) . '</td>';
|
|
echo '<td>' . esc_html((string) $db_sales) . '</td>';
|
|
echo '<td>' . esc_html((string) max(0, $meta_sales)) . '</td>';
|
|
echo '<td>' . esc_html($revenue) . '</td>';
|
|
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;">';
|
|
if ($edit_url) {
|
|
echo '<a class="button button-small" href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a>';
|
|
}
|
|
echo '<a class="button button-small" href="' . esc_url($orders_url) . '">' . esc_html__('订单', 'aksrt') . '</a>';
|
|
$permalink = get_permalink($post_id);
|
|
if ($permalink) {
|
|
echo '<a class="button button-small" target="_blank" rel="noopener noreferrer" href="' . esc_url($permalink) . '">' . esc_html__('查看', 'aksrt') . '</a>';
|
|
}
|
|
echo '</td>';
|
|
echo '</tr>';
|
|
}
|
|
|
|
// 翻页链接显式带上当前筛选条件(不含 paged),口径与 render_filters() 的表单一致。
|
|
$query_args = array_filter([
|
|
'pay_type' => $pay_type !== '' ? $pay_type : null,
|
|
's' => $search !== '' ? $search : null,
|
|
], static function ($value): bool {
|
|
return $value !== null;
|
|
});
|
|
|
|
echo '</tbody></table>';
|
|
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
|
|
$this->render_footer();
|
|
}
|
|
|
|
/**
|
|
* @param array{pay_type?:string,search?:string,page?:int,per_page?:int} $args
|
|
* @return array{posts: \WP_Post[], total: int}
|
|
*/
|
|
private function query_paid_posts(array $args): array {
|
|
$pay_type = sanitize_key((string) ($args['pay_type'] ?? ''));
|
|
$search = (string) ($args['search'] ?? '');
|
|
$page = max(1, (int) ($args['page'] ?? 1));
|
|
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
|
|
|
|
$meta_query = [
|
|
'relation' => 'AND',
|
|
[
|
|
'key' => \AKSRT\Core\DomainModel::PAY_META_KEY,
|
|
'compare' => 'EXISTS',
|
|
],
|
|
];
|
|
|
|
// Prefer denormalized type key when present; still exclude "no".
|
|
if ($pay_type !== '' && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_NO) {
|
|
$meta_query[] = [
|
|
'relation' => 'OR',
|
|
[
|
|
'key' => \AKSRT\Core\DomainModel::PAY_TYPE_KEY,
|
|
'value' => $pay_type,
|
|
'compare' => '=',
|
|
],
|
|
[
|
|
'key' => \AKSRT\Core\DomainModel::PAY_META_KEY,
|
|
'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"',
|
|
'compare' => 'LIKE',
|
|
],
|
|
];
|
|
} else {
|
|
$meta_query[] = [
|
|
'relation' => 'OR',
|
|
[
|
|
'key' => \AKSRT\Core\DomainModel::PAY_TYPE_KEY,
|
|
'value' => \AKSRT\Core\DomainModel::PAY_TYPE_NO,
|
|
'compare' => '!=',
|
|
],
|
|
[
|
|
'key' => \AKSRT\Core\DomainModel::PAY_TYPE_KEY,
|
|
'compare' => 'NOT EXISTS',
|
|
],
|
|
];
|
|
}
|
|
|
|
$q_args = [
|
|
'post_type' => 'post',
|
|
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
|
|
'posts_per_page' => $per_page,
|
|
'paged' => $page,
|
|
'orderby' => 'date',
|
|
'order' => 'DESC',
|
|
'meta_query' => $meta_query,
|
|
'fields' => 'all',
|
|
'no_found_rows' => false,
|
|
'update_post_meta_cache' => true,
|
|
'update_post_term_cache' => false,
|
|
];
|
|
|
|
if ($search !== '') {
|
|
if (ctype_digit($search)) {
|
|
$q_args['post__in'] = [(int) $search];
|
|
} else {
|
|
$q_args['s'] = $search;
|
|
}
|
|
}
|
|
|
|
$q = new \WP_Query($q_args);
|
|
$posts = [];
|
|
foreach ($q->posts as $post) {
|
|
if (!($post instanceof \WP_Post)) {
|
|
continue;
|
|
}
|
|
// Drop posts whose pay bag is effectively free/no.
|
|
if (function_exists('aksrt_is_paid_post') && !aksrt_is_paid_post($post)) {
|
|
continue;
|
|
}
|
|
if ($pay_type !== '' && function_exists('aksrt_get_pay_meta')) {
|
|
$meta = aksrt_get_pay_meta($post);
|
|
$pt = \AKSRT\Core\DomainModel::normalize_pay_type((string) ($meta['pay_type'] ?? ''));
|
|
if ($pt !== $pay_type) {
|
|
continue;
|
|
}
|
|
}
|
|
$posts[] = $post;
|
|
}
|
|
|
|
// When post-filtering drops rows, fall back to order-driven list if empty page
|
|
// so operators still see historical sold posts without pay meta.
|
|
if (empty($posts) && $search === '' && $pay_type === '' && $page === 1) {
|
|
$order_posts = $this->posts_from_order_summary($per_page, 0);
|
|
if (!empty($order_posts['posts'])) {
|
|
return $order_posts;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'posts' => $posts,
|
|
'total' => (int) $q->found_posts,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fallback: posts that already have paid resource orders.
|
|
*
|
|
* @return array{posts: \WP_Post[], total: int}
|
|
*/
|
|
private function posts_from_order_summary(int $limit, int $offset): array {
|
|
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
return ['posts' => [], 'total' => 0];
|
|
}
|
|
|
|
$summary = \AKSRT\Payment\OrderRepository::get_instance()->get_content_sales_summary([
|
|
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
|
'limit' => $limit,
|
|
'offset' => $offset,
|
|
'orderby' => 'sales_count',
|
|
'order' => 'DESC',
|
|
]);
|
|
|
|
$posts = [];
|
|
foreach ($summary['items'] as $item) {
|
|
$pid = (int) ($item['product_id'] ?? 0);
|
|
$post = $pid > 0 ? get_post($pid) : null;
|
|
if ($post instanceof \WP_Post && $post->post_type === 'post') {
|
|
$posts[] = $post;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'posts' => $posts,
|
|
'total' => (int) ($summary['total'] ?? count($posts)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $type_labels
|
|
*/
|
|
private function render_filters(string $pay_type, string $search, array $type_labels): void {
|
|
echo '<form method="get" style="margin:12px 0;">';
|
|
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
|
|
echo '<select name="pay_type">';
|
|
echo '<option value="">' . esc_html__('全部付费类型', 'aksrt') . '</option>';
|
|
foreach ($type_labels as $key => $label) {
|
|
printf(
|
|
'<option value="%s"%s>%s</option>',
|
|
esc_attr($key),
|
|
selected($pay_type, $key, false),
|
|
esc_html($label)
|
|
);
|
|
}
|
|
echo '</select> ';
|
|
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('标题或文章 ID', 'aksrt') . '" /> ';
|
|
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
|
echo '</form>';
|
|
|
|
echo '<ul class="subsubsub" style="margin:0 0 12px;float:none;">';
|
|
$base = $this->page_url(self::PAGE_SLUG);
|
|
$all_class = $pay_type === '' ? 'current' : '';
|
|
echo '<li><a class="' . esc_attr($all_class) . '" href="' . esc_url($base) . '">' . esc_html__('全部', 'aksrt') . '</a></li>';
|
|
foreach ($type_labels as $key => $label) {
|
|
$url = $this->page_url(self::PAGE_SLUG, array_filter([
|
|
'pay_type' => $key,
|
|
's' => $search !== '' ? $search : null,
|
|
]));
|
|
$cls = $pay_type === $key ? 'current' : '';
|
|
echo ' | <li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a></li>';
|
|
}
|
|
echo '</ul><div style="clear:both;"></div>';
|
|
echo '<p class="description">' . esc_html__('本页统计内容付费文章(product_type=resource),不含商城实体商品。订单销量来自已支付/已发货/已完成订单;Meta 销量为缓存计数。', 'aksrt') . '</p>';
|
|
}
|
|
|
|
private function ensure_cap(): void {
|
|
if (!current_user_can(self::CAPABILITY)) {
|
|
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
|
}
|
|
}
|
|
|
|
private function cash_decimal(string $value): string {
|
|
try {
|
|
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
|
} catch (\InvalidArgumentException $e) {
|
|
return '0.00';
|
|
}
|
|
}
|
|
|
|
private function render_header(string $title): void {
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html($title) . '</h1>';
|
|
}
|
|
|
|
private function render_footer(): void {
|
|
echo '</div>';
|
|
}
|
|
|
|
private function page_url(string $slug, array $args = []): string {
|
|
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
|
}
|
|
}
|