Merge pull request 'Worktree zibll native admin menus' (#156) from worktree-zibll-native-admin-menus into main
Reviewed-on: CYL/wordpress-theme-aksrt#156
This commit is contained in:
@@ -48,7 +48,7 @@ Focus: admin **entry & page shape**. Runtime field parity is Phase 5+.
|
||||
| `zibpay_order_page` | Orders (legacy) | SPA | same native list | |
|
||||
| `zibpay_page#/shipping` | Shipping | SPA | `aksrt_shipping` | `AKSRT_Shipping` |
|
||||
| `zibpay_page#/after-sale` | After-sale | SPA | `aksrt_after_sale` | `AKSRT_After_Sale` |
|
||||
| `zibpay_product_page` | Paid-post sales report | partial | optional later `aksrt_pay_products` | content pay |
|
||||
| `zibpay_product_page` | Paid-post sales report | done | `aksrt_pay_products` | content pay (`AKSRT_Admin_Pay_Products` + order aggregation) |
|
||||
| `zibpay_income_page` | Creator income | `PaymentRevenue` | `aksrt_income` | `AKSRT_Revenue` |
|
||||
| `zibpay_rebate_page` | Rebate | `PaymentRebate` | `aksrt_rebate` | `AKSRT_Rebate` |
|
||||
| `zibpay_withdraw` | Withdraw | `ShopWithdrawals` | `aksrt_withdraw` | `AKSRT_Withdrawal` |
|
||||
@@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no
|
||||
| 2b Shipping/after-sale/coupons/income/rebate | done |
|
||||
| 3 users.php submenus | done |
|
||||
| 4 SPA strip | done (ops routes redirect to native) |
|
||||
| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch |
|
||||
| 5 remaining | zibll pay-hide/points plate options out of scope |
|
||||
| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch + paid-post sales report (`aksrt_pay_products`) + topic pay/points visibility admin/runtime alignment |
|
||||
| 5 remaining | plate follow-pay (zibll paid follow) still optional; topic-level pay/points/hide-part done |
|
||||
|
||||
## Dual-UI rule
|
||||
|
||||
|
||||
@@ -371,6 +371,9 @@ if (is_admin() && class_exists('AKSRT_Admin_Pay_After_Sale')) {
|
||||
if (is_admin() && class_exists('AKSRT_Admin_Pay_Orders')) {
|
||||
AKSRT_Admin_Pay_Orders::get_instance()->init();
|
||||
}
|
||||
if (is_admin() && class_exists('AKSRT_Admin_Pay_Products')) {
|
||||
AKSRT_Admin_Pay_Products::get_instance()->init();
|
||||
}
|
||||
if (is_admin() && class_exists('AKSRT_Admin_Pay_Withdrawals')) {
|
||||
AKSRT_Admin_Pay_Withdrawals::get_instance()->init();
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-charge
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-shipping.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-after-sale.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-orders.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-products.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-income.php';
|
||||
require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-rebate.php';
|
||||
|
||||
@@ -54,6 +54,17 @@ class AKSRT_Admin_Pay_Menus {
|
||||
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'products' => [
|
||||
'slug' => 'aksrt_pay_products',
|
||||
'title' => __('付费文章', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT_Admin_Pay_Products')) {
|
||||
AKSRT_Admin_Pay_Products::get_instance()->render_products_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('付费文章模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'shipping' => [
|
||||
'slug' => 'aksrt_shipping',
|
||||
'title' => __('发货及物流', 'aksrt'),
|
||||
|
||||
@@ -48,10 +48,12 @@ class AKSRT_Admin_Pay_Orders {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$product_type = isset($_GET['product_type']) ? sanitize_key((string) wp_unslash($_GET['product_type'])) : '';
|
||||
$product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0;
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
|
||||
$repo = AKSRT_Order_Repository::get_instance();
|
||||
$args = [
|
||||
@@ -61,6 +63,12 @@ class AKSRT_Admin_Pay_Orders {
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$args['status'] = $status;
|
||||
}
|
||||
if ($product_type !== '') {
|
||||
$args['product_type'] = $product_type;
|
||||
}
|
||||
if ($product_id > 0) {
|
||||
$args['product_id'] = $product_id;
|
||||
}
|
||||
|
||||
$items = $repo->get_orders_admin($args, $search);
|
||||
$total = $repo->get_orders_count($args, $search);
|
||||
@@ -71,6 +79,12 @@ class AKSRT_Admin_Pay_Orders {
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_orders" />';
|
||||
if ($product_type !== '') {
|
||||
echo '<input type="hidden" name="product_type" value="' . esc_attr($product_type) . '" />';
|
||||
}
|
||||
if ($product_id > 0) {
|
||||
echo '<input type="hidden" name="product_id" value="' . esc_attr((string) $product_id) . '" />';
|
||||
}
|
||||
echo '<select name="status">';
|
||||
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
|
||||
foreach ($labels as $key => $label) {
|
||||
@@ -84,6 +98,17 @@ class AKSRT_Admin_Pay_Orders {
|
||||
echo '</select> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 类型', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($product_id > 0 || $product_type !== '') {
|
||||
$filter_bits = [];
|
||||
if ($product_type !== '') {
|
||||
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
|
||||
}
|
||||
if ($product_id > 0) {
|
||||
$filter_bits[] = sprintf(__('商品ID=%d', 'aksrt'), $product_id);
|
||||
}
|
||||
echo ' <span class="description">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除商品过滤', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped">';
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
/**
|
||||
* Native WP admin: paid content sales report (zibll zibpay_product_page parity).
|
||||
*
|
||||
* Lists content posts with pay config / resource orders — not shop_product CPT.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AKSRT_Admin_Pay_Products {
|
||||
|
||||
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_Order_Repository') || !class_exists('AKSRT_Domain_Model')) {
|
||||
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 = 20;
|
||||
|
||||
$type_labels = AKSRT_Domain_Model::pay_type_labels();
|
||||
unset($type_labels[AKSRT_Domain_Model::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 = $query['total'];
|
||||
$pages = max(1, (int) ceil($total / $per_page));
|
||||
|
||||
$sales_map = [];
|
||||
if ($posts && class_exists('AKSRT_Order_Repository')) {
|
||||
$ids = array_map(static function ($p) {
|
||||
return (int) $p->ID;
|
||||
}, $posts);
|
||||
$sales_map = AKSRT_Order_Repository::get_instance()->get_content_sales_map(
|
||||
$ids,
|
||||
AKSRT_Domain_Model::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_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::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 = (int) ceil((float) ($meta['points_price'] ?? $meta['price'] ?? 0));
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), $list_price);
|
||||
} else {
|
||||
$list_price = round((float) ($meta['price'] ?? 0), 2);
|
||||
$price_text = number_format($list_price, 2);
|
||||
}
|
||||
|
||||
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => 0.0, 'revenue' => 0.0];
|
||||
$db_sales = (int) ($sales['sales_count'] ?? 0);
|
||||
$avg = (float) ($sales['avg_amount'] ?? 0);
|
||||
$revenue = (float) ($sales['revenue'] ?? 0);
|
||||
$meta_sales = (int) get_post_meta($post_id, AKSRT_Domain_Model::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_Domain_Model::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(number_format($avg, 2)) . '</td>';
|
||||
echo '<td>' . esc_html((string) $db_sales) . '</td>';
|
||||
echo '<td>' . esc_html((string) max(0, $meta_sales)) . '</td>';
|
||||
echo '<td>' . esc_html(number_format($revenue, 2)) . '</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>';
|
||||
}
|
||||
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
$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_Domain_Model::PAY_META_KEY,
|
||||
'compare' => 'EXISTS',
|
||||
],
|
||||
];
|
||||
|
||||
// Prefer denormalized type key when present; still exclude "no".
|
||||
if ($pay_type !== '' && $pay_type !== AKSRT_Domain_Model::PAY_TYPE_NO) {
|
||||
$meta_query[] = [
|
||||
'relation' => 'OR',
|
||||
[
|
||||
'key' => AKSRT_Domain_Model::PAY_TYPE_KEY,
|
||||
'value' => $pay_type,
|
||||
'compare' => '=',
|
||||
],
|
||||
[
|
||||
'key' => AKSRT_Domain_Model::PAY_META_KEY,
|
||||
'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"',
|
||||
'compare' => 'LIKE',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$meta_query[] = [
|
||||
'relation' => 'OR',
|
||||
[
|
||||
'key' => AKSRT_Domain_Model::PAY_TYPE_KEY,
|
||||
'value' => AKSRT_Domain_Model::PAY_TYPE_NO,
|
||||
'compare' => '!=',
|
||||
],
|
||||
[
|
||||
'key' => AKSRT_Domain_Model::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_Domain_Model::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_Order_Repository')) {
|
||||
return ['posts' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$summary = AKSRT_Order_Repository::get_instance()->get_content_sales_summary([
|
||||
'product_type' => AKSRT_Domain_Model::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 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'));
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">';
|
||||
echo paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page_num,
|
||||
]);
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -408,21 +408,7 @@ class AKSRT_Order_Repository {
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
@@ -442,21 +428,7 @@ class AKSRT_Order_Repository {
|
||||
public function get_orders_count(array $args = [], string $search = ''): int {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
@@ -467,6 +439,192 @@ class AKSRT_Order_Repository {
|
||||
return (int) $wpdb->get_var($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate paid content (resource) sales by product_id (post ID).
|
||||
*
|
||||
* @param array{
|
||||
* product_type?: string,
|
||||
* product_ids?: int[],
|
||||
* status_in?: string[],
|
||||
* limit?: int,
|
||||
* offset?: int,
|
||||
* orderby?: string,
|
||||
* order?: string
|
||||
* } $args
|
||||
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:float,revenue:float}>, total: int}
|
||||
*/
|
||||
public function get_content_sales_summary(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE));
|
||||
if ($product_type === '') {
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$status_in = $args['status_in'] ?? AKSRT_Domain_Model::paid_order_statuses();
|
||||
if (!is_array($status_in) || empty($status_in)) {
|
||||
$status_in = AKSRT_Domain_Model::paid_order_statuses();
|
||||
}
|
||||
$status_in = array_values(array_filter(array_map('sanitize_key', $status_in)));
|
||||
if (empty($status_in)) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$where = ['product_type = %s', 'product_id > 0'];
|
||||
$params = [$product_type];
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
|
||||
$where[] = "status IN ({$status_placeholders})";
|
||||
foreach ($status_in as $st) {
|
||||
$params[] = $st;
|
||||
}
|
||||
|
||||
$product_ids = $args['product_ids'] ?? [];
|
||||
if (is_array($product_ids) && !empty($product_ids)) {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (!empty($ids)) {
|
||||
$id_ph = implode(', ', array_fill(0, count($ids), '%d'));
|
||||
$where[] = "product_id IN ({$id_ph})";
|
||||
foreach ($ids as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$table = $this->table();
|
||||
|
||||
$total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}";
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params));
|
||||
|
||||
$orderby_map = [
|
||||
'sales_count' => 'sales_count',
|
||||
'revenue' => 'revenue',
|
||||
'avg_amount' => 'avg_amount',
|
||||
'product_id' => 'product_id',
|
||||
];
|
||||
$orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count'));
|
||||
$orderby_col = $orderby_map[$orderby_key] ?? 'sales_count';
|
||||
$order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$list_sql = "SELECT product_id,
|
||||
product_type,
|
||||
COUNT(*) AS sales_count,
|
||||
AVG(amount) AS avg_amount,
|
||||
SUM(amount) AS revenue
|
||||
FROM {$table}
|
||||
{$where_sql}
|
||||
GROUP BY product_id, product_type
|
||||
ORDER BY {$orderby_col} {$order_dir}
|
||||
LIMIT %d OFFSET %d";
|
||||
$list_params = $params;
|
||||
$list_params[] = $limit;
|
||||
$list_params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$items[] = [
|
||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||
'product_type' => (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => (int) ($row['sales_count'] ?? 0),
|
||||
'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2),
|
||||
'revenue' => round((float) ($row['revenue'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paid order totals for a set of product IDs (resource content).
|
||||
*
|
||||
* @param int[] $product_ids
|
||||
* @return array<int, array{sales_count:int,avg_amount:float,revenue:float}>
|
||||
*/
|
||||
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($product_type === '') {
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$summary = $this->get_content_sales_summary([
|
||||
'product_type' => $product_type,
|
||||
'product_ids' => $ids,
|
||||
'limit' => max(count($ids), 1),
|
||||
'offset' => 0,
|
||||
'orderby' => 'product_id',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
$map = [];
|
||||
foreach ($summary['items'] as $item) {
|
||||
$pid = (int) ($item['product_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$map[$pid] = [
|
||||
'sales_count' => (int) ($item['sales_count'] ?? 0),
|
||||
'avg_amount' => (float) ($item['avg_amount'] ?? 0),
|
||||
'revenue' => (float) ($item['revenue'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared WHERE builder for admin order list/count.
|
||||
*
|
||||
* @return array{0: string[], 1: array<int, mixed>}
|
||||
*/
|
||||
private function build_admin_order_filters(array $args, string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$product_id = (int) ($args['product_id'] ?? 0);
|
||||
if ($product_id > 0) {
|
||||
$where[] = 'product_id = %d';
|
||||
$params[] = $product_id;
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
return [$where, $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单统计
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
/**
|
||||
* Native topic governance meta box for aksrt_forum_topic.
|
||||
*
|
||||
* Visibility / pay / credits contract (runtime source of truth):
|
||||
* - `_aksrt_visibility` public|login|comment|role|credits|pay|moderator
|
||||
* - `_aksrt_credits_cost` points price when visibility=credits
|
||||
* - `_aksrt_pay_price` cash price when visibility=pay
|
||||
* - `_aksrt_pay_vip_price` optional VIP cash (0 = free)
|
||||
* - `_aksrt_visibility_roles` roles when visibility=role
|
||||
* - `_aksrt_pay_hide_part` partial hide flag (full hide still default)
|
||||
*
|
||||
* Legacy `_aksrt_pay_credits` / topic `_aksrt_allow_view` are rewritten on save.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
@@ -9,6 +19,16 @@ defined('ABSPATH') || exit;
|
||||
|
||||
class AKSRT_Topic_Meta_Box {
|
||||
|
||||
private const VISIBILITY = [
|
||||
'public',
|
||||
'login',
|
||||
'comment',
|
||||
'role',
|
||||
'credits',
|
||||
'pay',
|
||||
'moderator',
|
||||
];
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
@@ -49,10 +69,27 @@ class AKSRT_Topic_Meta_Box {
|
||||
$review = 'approved';
|
||||
}
|
||||
$bbs_type = (string) (get_post_meta($post->ID, '_aksrt_bbs_type', true) ?: 'standard');
|
||||
$visibility = (string) (get_post_meta($post->ID, '_aksrt_visibility', true) ?: 'public');
|
||||
$allow_view = (string) (get_post_meta($post->ID, '_aksrt_allow_view', true) ?: 'public');
|
||||
$pay_price = (float) get_post_meta($post->ID, '_aksrt_pay_price', true);
|
||||
$pay_credits= (int) get_post_meta($post->ID, '_aksrt_pay_credits', true);
|
||||
$visibility = sanitize_key((string) (get_post_meta($post->ID, '_aksrt_visibility', true) ?: 'public'));
|
||||
if (!in_array($visibility, self::VISIBILITY, true)) {
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
$credits_cost = (int) get_post_meta($post->ID, '_aksrt_credits_cost', true);
|
||||
if ($credits_cost <= 0) {
|
||||
// Legacy dual key.
|
||||
$credits_cost = max(0, (int) get_post_meta($post->ID, '_aksrt_pay_credits', true));
|
||||
}
|
||||
$pay_price = (float) get_post_meta($post->ID, '_aksrt_pay_price', true);
|
||||
$pay_vip_price = get_post_meta($post->ID, '_aksrt_pay_vip_price', true);
|
||||
$vip_set = ($pay_vip_price !== '' && $pay_vip_price !== null && $pay_vip_price !== false);
|
||||
$vip_price_val = $vip_set ? (float) $pay_vip_price : '';
|
||||
$pay_hide_part = (int) get_post_meta($post->ID, '_aksrt_pay_hide_part', true) === 1;
|
||||
$view_roles = get_post_meta($post->ID, '_aksrt_visibility_roles', true);
|
||||
if (!is_array($view_roles)) {
|
||||
$view_roles = [];
|
||||
}
|
||||
$view_roles = array_values(array_filter(array_map('sanitize_key', $view_roles)));
|
||||
$wp_roles = $this->wp_role_options();
|
||||
|
||||
$plates = get_posts([
|
||||
'post_type' => 'aksrt_forum_plate',
|
||||
@@ -61,6 +98,16 @@ class AKSRT_Topic_Meta_Box {
|
||||
'orderby' => 'title',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
$visibility_labels = [
|
||||
'public' => __('公开', 'aksrt'),
|
||||
'login' => __('登录可见', 'aksrt'),
|
||||
'comment' => __('评论可见', 'aksrt'),
|
||||
'role' => __('指定角色', 'aksrt'),
|
||||
'credits' => __('支付积分后可见', 'aksrt'),
|
||||
'pay' => __('付费后可见', 'aksrt'),
|
||||
'moderator' => __('仅版主', 'aksrt'),
|
||||
];
|
||||
?>
|
||||
<p>
|
||||
<label for="aksrt_plate_id"><strong><?php esc_html_e('所属版块', 'aksrt'); ?></strong></label>
|
||||
@@ -104,26 +151,42 @@ class AKSRT_Topic_Meta_Box {
|
||||
<p>
|
||||
<label for="aksrt_visibility"><strong><?php esc_html_e('可见性', 'aksrt'); ?></strong></label>
|
||||
<select name="aksrt_visibility" id="aksrt_visibility" class="widefat">
|
||||
<?php foreach (['public' => __('公开', 'aksrt'), 'login' => __('登录可见', 'aksrt'), 'comment' => __('评论可见', 'aksrt'), 'role' => __('角色', 'aksrt'), 'credits' => __('积分', 'aksrt'), 'pay' => __('付费', 'aksrt'), 'moderator' => __('版主', 'aksrt')] as $k => $label) : ?>
|
||||
<?php foreach ($visibility_labels as $k => $label) : ?>
|
||||
<option value="<?php echo esc_attr($k); ?>" <?php selected($visibility, $k); ?>><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_allow_view"><strong><?php esc_html_e('允许查看', 'aksrt'); ?></strong></label>
|
||||
<select name="aksrt_allow_view" id="aksrt_allow_view" class="widefat">
|
||||
<?php foreach (['public' => __('公开', 'aksrt'), 'login' => __('登录', 'aksrt'), 'pay' => __('付费', 'aksrt'), 'moderator' => __('版主', 'aksrt')] as $k => $label) : ?>
|
||||
<option value="<?php echo esc_attr($k); ?>" <?php selected($allow_view, $k); ?>><?php echo esc_html($label); ?></option>
|
||||
<p class="aksrt-topic-meta-role-fields">
|
||||
<label for="aksrt_visibility_roles"><strong><?php esc_html_e('可查看角色', 'aksrt'); ?></strong></label>
|
||||
<select id="aksrt_visibility_roles" name="aksrt_visibility_roles[]" class="widefat" multiple size="4">
|
||||
<?php foreach ($wp_roles as $role_key => $role_label) : ?>
|
||||
<option value="<?php echo esc_attr($role_key); ?>" <?php selected(in_array($role_key, $view_roles, true)); ?>>
|
||||
<?php echo esc_html($role_label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="description"><?php esc_html_e('仅「指定角色」时生效。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_pay_price"><strong><?php esc_html_e('付费价格', 'aksrt'); ?></strong></label>
|
||||
<p class="aksrt-topic-meta-credits-fields">
|
||||
<label for="aksrt_credits_cost"><strong><?php esc_html_e('积分价格', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="1" min="0" class="widefat" name="aksrt_credits_cost" id="aksrt_credits_cost" value="<?php echo esc_attr((string) $credits_cost); ?>">
|
||||
<span class="description"><?php esc_html_e('可见性为「支付积分后可见」时使用。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<p class="aksrt-topic-meta-pay-fields">
|
||||
<label for="aksrt_pay_price"><strong><?php esc_html_e('付费价格(元)', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_price" id="aksrt_pay_price" value="<?php echo esc_attr((string) $pay_price); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_pay_credits"><strong><?php esc_html_e('付费积分', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="1" min="0" class="widefat" name="aksrt_pay_credits" id="aksrt_pay_credits" value="<?php echo esc_attr((string) $pay_credits); ?>">
|
||||
<p class="aksrt-topic-meta-pay-fields">
|
||||
<label for="aksrt_pay_vip_price"><strong><?php esc_html_e('VIP 价格(元)', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_vip_price" id="aksrt_pay_vip_price" value="<?php echo esc_attr($vip_set ? (string) $vip_price_val : ''); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP 价', 'aksrt'); ?>">
|
||||
<span class="description"><?php esc_html_e('填 0 表示 VIP 免费;留空则 VIP 与普通同价。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<p class="aksrt-topic-meta-pay-hide-fields">
|
||||
<label>
|
||||
<input type="checkbox" name="aksrt_pay_hide_part" value="1" <?php checked($pay_hide_part); ?>>
|
||||
<?php esc_html_e('仅隐藏部分内容', 'aksrt'); ?>
|
||||
</label>
|
||||
<span class="description" style="display:block;"><?php esc_html_e('开启后仍以全文门控为默认;部分隐藏依赖正文隐藏块。未解锁用户正文不会下发。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
@@ -159,14 +222,7 @@ class AKSRT_Topic_Meta_Box {
|
||||
}
|
||||
update_post_meta($post_id, '_aksrt_bbs_type', $bbs_type);
|
||||
|
||||
$visibility = isset($_POST['aksrt_visibility']) ? sanitize_key((string) wp_unslash($_POST['aksrt_visibility'])) : 'public';
|
||||
update_post_meta($post_id, '_aksrt_visibility', $visibility);
|
||||
|
||||
$allow_view = isset($_POST['aksrt_allow_view']) ? sanitize_key((string) wp_unslash($_POST['aksrt_allow_view'])) : 'public';
|
||||
update_post_meta($post_id, '_aksrt_allow_view', $allow_view);
|
||||
|
||||
update_post_meta($post_id, '_aksrt_pay_price', isset($_POST['aksrt_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_pay_price'])) : 0);
|
||||
update_post_meta($post_id, '_aksrt_pay_credits', isset($_POST['aksrt_pay_credits']) ? max(0, (int) wp_unslash($_POST['aksrt_pay_credits'])) : 0);
|
||||
$this->save_visibility_meta($post_id);
|
||||
|
||||
// Keep board taxonomy bridge roughly in sync when plate selected.
|
||||
if ($plate_id > 0 && taxonomy_exists('aksrt_forum_board')) {
|
||||
@@ -188,4 +244,106 @@ class AKSRT_Topic_Meta_Box {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function save_visibility_meta(int $post_id): void {
|
||||
$visibility = isset($_POST['aksrt_visibility']) ? sanitize_key((string) wp_unslash($_POST['aksrt_visibility'])) : 'public';
|
||||
if (!in_array($visibility, self::VISIBILITY, true)) {
|
||||
$visibility = 'public';
|
||||
}
|
||||
update_post_meta($post_id, '_aksrt_visibility', $visibility);
|
||||
|
||||
// Rewrite legacy dual allow_view so admin/runtime stay on one contract.
|
||||
$allow_view_map = [
|
||||
'public' => 'public',
|
||||
'login' => 'login',
|
||||
'comment' => 'login',
|
||||
'role' => 'public',
|
||||
'credits' => 'pay',
|
||||
'pay' => 'pay',
|
||||
'moderator' => 'moderator',
|
||||
];
|
||||
update_post_meta($post_id, '_aksrt_allow_view', $allow_view_map[$visibility] ?? 'public');
|
||||
|
||||
// Roles.
|
||||
if ($visibility === 'role') {
|
||||
$roles = $this->sanitize_role_list($_POST['aksrt_visibility_roles'] ?? []);
|
||||
update_post_meta($post_id, '_aksrt_visibility_roles', $roles);
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_visibility_roles');
|
||||
}
|
||||
|
||||
// Credits (canonical + legacy dual-write).
|
||||
if ($visibility === 'credits') {
|
||||
$cost = isset($_POST['aksrt_credits_cost']) ? max(0, (int) wp_unslash($_POST['aksrt_credits_cost'])) : 0;
|
||||
// Accept legacy field name if UI still posts it.
|
||||
if ($cost <= 0 && isset($_POST['aksrt_pay_credits'])) {
|
||||
$cost = max(0, (int) wp_unslash($_POST['aksrt_pay_credits']));
|
||||
}
|
||||
update_post_meta($post_id, '_aksrt_credits_cost', $cost);
|
||||
update_post_meta($post_id, '_aksrt_pay_credits', $cost);
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_credits_cost');
|
||||
delete_post_meta($post_id, '_aksrt_pay_credits');
|
||||
}
|
||||
|
||||
// Cash pay.
|
||||
if ($visibility === 'pay') {
|
||||
$price = isset($_POST['aksrt_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_pay_price'])) : 0;
|
||||
update_post_meta($post_id, '_aksrt_pay_price', $price);
|
||||
|
||||
if (isset($_POST['aksrt_pay_vip_price']) && (string) wp_unslash($_POST['aksrt_pay_vip_price']) !== '') {
|
||||
$vip = max(0, (float) wp_unslash($_POST['aksrt_pay_vip_price']));
|
||||
update_post_meta($post_id, '_aksrt_pay_vip_price', $vip);
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_pay_vip_price');
|
||||
}
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_pay_price');
|
||||
delete_post_meta($post_id, '_aksrt_pay_vip_price');
|
||||
}
|
||||
|
||||
// Partial hide only meaningful for pay/credits; otherwise clear.
|
||||
if (in_array($visibility, ['pay', 'credits'], true)) {
|
||||
update_post_meta($post_id, '_aksrt_pay_hide_part', !empty($_POST['aksrt_pay_hide_part']) ? 1 : 0);
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_pay_hide_part');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return string[]
|
||||
*/
|
||||
private function sanitize_role_list($raw): array {
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$allowed = array_keys($this->wp_role_options());
|
||||
$out = [];
|
||||
foreach ($raw as $role) {
|
||||
$role = sanitize_key((string) $role);
|
||||
if ($role !== '' && in_array($role, $allowed, true)) {
|
||||
$out[] = $role;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function wp_role_options(): array {
|
||||
if (!function_exists('wp_roles')) {
|
||||
return [];
|
||||
}
|
||||
$roles = wp_roles();
|
||||
if (!$roles || !is_array($roles->roles)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($roles->roles as $key => $data) {
|
||||
$out[sanitize_key((string) $key)] = (string) ($data['name'] ?? $key);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,17 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
|
||||
? $post->post_excerpt
|
||||
: wp_trim_words(wp_strip_all_tags((string) $post->post_content), 40);
|
||||
|
||||
$pay_hide_part = (int) get_post_meta($topic_id, '_aksrt_pay_hide_part', true) === 1;
|
||||
$content = (string) $post->post_content;
|
||||
if (!$can_view) {
|
||||
// Prevent content leak via payload/API when template forgets can_view check.
|
||||
// Partial-hide flag only applies after unlock; denied users get empty body.
|
||||
$content = '';
|
||||
if (in_array($visibility, [AKSRT_Forum::VISIBILITY_CREDITS, AKSRT_Forum::VISIBILITY_PAY, AKSRT_Forum::VISIBILITY_LOGIN, AKSRT_Forum::VISIBILITY_COMMENT, AKSRT_Forum::VISIBILITY_ROLE, AKSRT_Forum::VISIBILITY_MODERATOR], true)) {
|
||||
$excerpt = '';
|
||||
}
|
||||
}
|
||||
|
||||
$sticky_level = $this->resolve_sticky_level($topic_id);
|
||||
$featured = get_post_meta($topic_id, '_aksrt_featured', true) === '1';
|
||||
$locked = get_post_meta($topic_id, '_aksrt_locked', true) === '1';
|
||||
@@ -81,11 +92,16 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
|
||||
|
||||
$cover = $this->build_cover_payload($topic_id);
|
||||
|
||||
$credits_cost = max(0, (int) get_post_meta($topic_id, '_aksrt_credits_cost', true));
|
||||
if ($credits_cost <= 0) {
|
||||
$credits_cost = max(0, (int) get_post_meta($topic_id, '_aksrt_pay_credits', true));
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $topic_id,
|
||||
'title' => (string) get_the_title($topic_id),
|
||||
'excerpt' => (string) $excerpt,
|
||||
'content' => (string) $post->post_content,
|
||||
'content' => $content,
|
||||
'status' => (string) $post->post_status,
|
||||
'author_id' => $author_id,
|
||||
'author' => $this->build_user_summary($author_id),
|
||||
@@ -127,6 +143,9 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
|
||||
'can_view' => $can_view,
|
||||
'can_edit' => $this->forum->can_edit_topic($viewer_user_id, $topic_id),
|
||||
'can_moderate' => $this->forum->can_moderate_topic($viewer_user_id, $topic_id),
|
||||
'pay_hide_part' => $pay_hide_part,
|
||||
'credits_cost' => $credits_cost,
|
||||
'pay_price' => (float) get_post_meta($topic_id, '_aksrt_pay_price', true),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -327,8 +327,10 @@ function aksrt_register_forum_topic_meta() {
|
||||
'_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'],
|
||||
'_aksrt_pay_price' => ['type' => 'number', 'default' => 0],
|
||||
'_aksrt_pay_vip_price' => ['type' => 'number', 'default' => 0],
|
||||
'_aksrt_pay_credits' => ['type' => 'integer', 'default' => 0],
|
||||
'_aksrt_pay_credits' => ['type' => 'integer', 'default' => 0], // legacy alias of credits_cost
|
||||
'_aksrt_credits_cost' => ['type' => 'integer', 'default' => 0], // runtime unlock price
|
||||
'_aksrt_pay_hide_part' => ['type' => 'integer', 'default' => 0],
|
||||
'_aksrt_visibility_roles' => ['type' => 'array', 'default' => []],
|
||||
|
||||
// 状态工作流扩展位 (Wave4)
|
||||
'_aksrt_closed' => ['type' => 'integer', 'default' => 0],
|
||||
|
||||
Reference in New Issue
Block a user