feat(admin): quick-edit paid post settings in a dialog on the pay products page
- Rebuild aksrt_pay_products as list + in-page quick settings dialog (pay type, limit, cash/points prices, sales float, coupon, order expiry, copy) - Query by _aksrt_pay_type with real pagination; drop order-summary fallback and the duplicate "Meta 销量" column - Centralize pay meta writes in DomainModel::persist_pay_meta(); stop writing unread _aksrt_pay_modo / _aksrt_pay_limit keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4083613dbe
commit
dc6dbc05d8
11 files changed
+983
-349
No files matched your search
+100
-310
@@ -1,16 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* 支付中心「付费文章」:商品列表 + 页内快速设置。
|
||||
*
|
||||
* 快速设置只覆盖售卖相关字段(付费类型、购买权限、价格、销量、优惠码、订单时效、商品文案);
|
||||
* 下载资源、付费图片、视频剧集等内容型字段仍在文章编辑页的「付费功能」面板维护。
|
||||
* 保存走 admin-post,写入统一经 DomainModel::persist_pay_meta(),成功后带 flash 回到原列表位置。
|
||||
*
|
||||
* 类体拆成 flat sibling traits(PSR-4 要求 trait 与宿主类同目录):
|
||||
* - PayProductsList 列表查询与表格渲染
|
||||
* - PayProductsEditor 快速设置弹窗与保存
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Core\DomainModel;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayProducts {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_pay_products';
|
||||
use PayProductsList;
|
||||
use PayProductsEditor;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_pay_products';
|
||||
public const SAVE_ACTION = 'aksrt_pay_save_product';
|
||||
|
||||
/** 列表纳入的文章状态。 */
|
||||
private const LIST_STATUSES = ['publish', 'future', 'draft', 'pending', 'private'];
|
||||
|
||||
/** 快速设置可改写的付费 meta 键;其余键保存时原样保留。 */
|
||||
private const QUICK_FIELDS = [
|
||||
'pay_type', 'pay_limit', 'pay_modo',
|
||||
'price', 'original_price', 'promotion_tag', 'vip1_price', 'vip2_price',
|
||||
'points_price', 'vip1_points', 'vip2_points',
|
||||
'pay_count', 'show_sales', 'coupon_s',
|
||||
'order_expired_s', 'order_expired_time', 'order_expired_unit',
|
||||
'pay_title', 'pay_doc',
|
||||
];
|
||||
private const CASH_FIELDS = ['price', 'original_price', 'vip1_price', 'vip2_price'];
|
||||
private const POINT_FIELDS = ['points_price', 'vip1_points', 'vip2_points'];
|
||||
|
||||
private const TYPE_TONES = [
|
||||
DomainModel::PAY_TYPE_READING => 'danger',
|
||||
DomainModel::PAY_TYPE_DOWNLOAD => 'warning',
|
||||
DomainModel::PAY_TYPE_GALLERY => 'success',
|
||||
DomainModel::PAY_TYPE_VIDEO => 'info',
|
||||
];
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
@@ -22,335 +60,86 @@ class PayProducts {
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
// Read-only page; menu callback only.
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||
add_action('admin_post_' . self::SAVE_ACTION, [$this, 'handle_save']);
|
||||
}
|
||||
|
||||
public function enqueue_assets(): void {
|
||||
$page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : '';
|
||||
if ($page !== self::PAGE_SLUG) {
|
||||
return;
|
||||
}
|
||||
$css = '/assets/css/admin/pay-products.css';
|
||||
$js = '/assets/js/admin-pay-products.js';
|
||||
wp_enqueue_style('aksrt-admin-pay-products', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
|
||||
wp_enqueue_script('aksrt-admin-pay-products', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
|
||||
}
|
||||
|
||||
public function render_products_page(): void {
|
||||
$this->ensure_cap();
|
||||
NativeUi::page_open();
|
||||
NativeUi::header(__('付费文章', 'aksrt'), '', 'dashicons-media-document');
|
||||
NativeUi::page_open('aksrt-products-page');
|
||||
NativeUi::header(
|
||||
__('付费文章', 'aksrt'),
|
||||
__('管理付费内容的售卖配置。点击「快速设置」即可在本页修改价格、权限与销量,无需进入文章编辑页。', 'aksrt'),
|
||||
'dashicons-media-document'
|
||||
);
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
|
||||
NativeUi::page_close();
|
||||
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);
|
||||
|
||||
NativeUi::table_card_open(__('付费文章', 'aksrt'));
|
||||
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 NativeUi::empty_row(10, __('暂无付费文章。', 'aksrt'));
|
||||
}
|
||||
|
||||
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 class="aksrt-native-inline">';
|
||||
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>';
|
||||
NativeUi::table_wrap_close();
|
||||
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
|
||||
NativeUi::card_close();
|
||||
$this->render_flash();
|
||||
$context = $this->render_product_list();
|
||||
$this->render_product_editor($context);
|
||||
NativeUi::page_close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 array<string, string> 付费类型(不含「关闭」)。 */
|
||||
private function paid_type_labels(): array {
|
||||
$labels = DomainModel::pay_type_labels();
|
||||
unset($labels[DomainModel::PAY_TYPE_NO]);
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function pay_limit_labels(): array {
|
||||
return [
|
||||
'posts' => $posts,
|
||||
'total' => (int) $q->found_posts,
|
||||
0 => __('所有人可购买', 'aksrt'),
|
||||
1 => __('VIP1 及以上可购买', 'aksrt'),
|
||||
2 => __('仅 VIP2 可购买', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)),
|
||||
];
|
||||
private function currency_symbol(): string {
|
||||
$paid = \AKSRT\Settings\Settings::get_paid_content_settings();
|
||||
return (string) ($paid['display']['currency_symbol'] ?? '¥');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $type_labels
|
||||
*/
|
||||
private function render_filters(string $pay_type, string $search, array $type_labels): void {
|
||||
echo '<ul class="subsubsub aksrt-native-tabs">';
|
||||
$base = $this->page_url(self::PAGE_SLUG);
|
||||
$all_class = $pay_type === '' ? 'is-active' : '';
|
||||
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 ? 'is-active' : '';
|
||||
echo '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a></li>';
|
||||
private function render_flash(): void {
|
||||
$code = isset($_GET['aksrt_pay_msg']) ? sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])) : '';
|
||||
if ($code === '') {
|
||||
return;
|
||||
}
|
||||
echo '</ul>';
|
||||
|
||||
NativeUi::card_open(__('筛选', 'aksrt'));
|
||||
echo '<form method="get" class="aksrt-native-filters">';
|
||||
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)
|
||||
);
|
||||
$post = get_post((int) ($_GET['product'] ?? 0));
|
||||
$title = $post instanceof \WP_Post ? get_the_title($post) : '';
|
||||
// 码 => [文案, 是否失败]
|
||||
$map = [
|
||||
'product_saved' => [sprintf(__('已保存「%s」的商品配置。', 'aksrt'), $title), false],
|
||||
'product_disabled' => [sprintf(__('已关闭「%s」的付费功能,文章已移出本列表。', 'aksrt'), $title), false],
|
||||
'product_invalid_amount' => [__('保存失败:金额最多两位小数,积分须为非负整数。', 'aksrt'), true],
|
||||
'product_vip_price' => [__('保存失败:会员价格不能高于执行价(积分商品不能高于积分售价)。', 'aksrt'), true],
|
||||
'product_missing' => [__('保存失败:文章不存在或您无权编辑。', 'aksrt'), true],
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
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 '<p class="description">' . esc_html__('本页统计内容付费文章(product_type=resource),不含商城实体商品。订单销量来自已支付/已发货/已完成订单;Meta 销量为缓存计数。', 'aksrt') . '</p>';
|
||||
NativeUi::card_close();
|
||||
[$message, $failed] = $map[$code];
|
||||
echo '<div class="notice ' . esc_attr($failed ? 'notice-error' : 'notice-success') . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
|
||||
}
|
||||
|
||||
private function ensure_cap(): void {
|
||||
@@ -367,7 +156,8 @@ class PayProducts {
|
||||
}
|
||||
}
|
||||
|
||||
/** 参数值统一 urlencode:add_query_arg 不编码值,搜索词里的 & / # 会截断链接。 */
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
return add_query_arg(urlencode_deep(array_merge(['page' => $slug], $args)), admin_url('admin.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
/**
|
||||
* 付费文章:快速设置弹窗与保存。
|
||||
*
|
||||
* 作为 flat sibling trait 被 PayProducts 使用(PSR-4:trait 必须与宿主类同目录)。
|
||||
* 弹窗是单个 <dialog>,由 admin-pay-products.js 按行内 data-aksrt-product-edit 的 JSON 填充。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Core\DomainModel;
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait PayProductsEditor {
|
||||
|
||||
/**
|
||||
* 行按钮携带的弹窗数据:只含快速设置覆盖的字段。
|
||||
*
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function product_payload(\WP_Post $post, array $meta, string $edit_url): array {
|
||||
$fields = [];
|
||||
foreach (self::QUICK_FIELDS as $key) {
|
||||
$value = $meta[$key] ?? '';
|
||||
$fields[$key] = is_bool($value) ? ($value ? '1' : '') : (string) $value;
|
||||
}
|
||||
$title = html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8');
|
||||
return [
|
||||
'id' => (int) $post->ID,
|
||||
'title' => $title !== '' ? $title : sprintf(__('(无标题 #%d)', 'aksrt'), (int) $post->ID),
|
||||
'editUrl' => $edit_url,
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, string|int> $context 列表筛选与页码,保存后据此回到原位置。 */
|
||||
private function render_product_editor(array $context): void {
|
||||
$currency = $this->currency_symbol();
|
||||
echo '<dialog class="aksrt-product-dialog" id="aksrt-product-editor" aria-labelledby="aksrt-product-editor-title" aria-describedby="aksrt-product-editor-subject">';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-product-dialog__frame" data-aksrt-product-form'
|
||||
. ' data-discard="' . esc_attr__('有未保存的修改,确定放弃?', 'aksrt') . '"'
|
||||
. ' data-vip-error="' . esc_attr__('会员价格不能高于执行价。', 'aksrt') . '"'
|
||||
. ' data-vip-points-error="' . esc_attr__('会员积分价不能高于积分售价。', 'aksrt') . '"'
|
||||
. ' data-busy-label="' . esc_attr__('正在保存…', 'aksrt') . '">';
|
||||
echo '<input type="hidden" name="action" value="' . esc_attr(self::SAVE_ACTION) . '">';
|
||||
echo '<input type="hidden" name="post_id" value="">';
|
||||
wp_nonce_field(self::SAVE_ACTION, '_wpnonce', false);
|
||||
foreach ($context as $key => $value) {
|
||||
echo '<input type="hidden" name="ctx[' . esc_attr((string) $key) . ']" value="' . esc_attr((string) $value) . '">';
|
||||
}
|
||||
|
||||
echo '<header class="aksrt-product-dialog__head"><div>';
|
||||
echo '<h2 id="aksrt-product-editor-title">' . esc_html__('快速设置', 'aksrt') . '</h2>';
|
||||
echo '<p id="aksrt-product-editor-subject" data-aksrt-product-subject></p></div>';
|
||||
echo '<button type="button" class="aksrt-product-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button>';
|
||||
echo '</header><div class="aksrt-product-dialog__body">';
|
||||
|
||||
$this->editor_section_open(__('售卖方式', 'aksrt'));
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('付费类型', 'aksrt') . '</span><select name="aksrt_pay[pay_type]">';
|
||||
foreach ($this->paid_type_labels() as $key => $label) {
|
||||
echo '<option value="' . esc_attr($key) . '">' . esc_html($label) . '</option>';
|
||||
}
|
||||
echo '<option value="' . esc_attr(DomainModel::PAY_TYPE_NO) . '">' . esc_html__('关闭付费', 'aksrt') . '</option></select></label>';
|
||||
$this->editor_radios('pay_modo', __('支付方式', 'aksrt'), ['0' => __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')], 'enabled');
|
||||
echo '</div>';
|
||||
echo '<p class="aksrt-native-callout is-warning" data-aksrt-product-pane="disabled" hidden>'
|
||||
. esc_html__('关闭后文章恢复免费阅读并移出本列表,价格等配置会保留,重新开启即可恢复。', 'aksrt') . '</p>';
|
||||
echo '<div data-aksrt-product-pane="enabled">';
|
||||
$this->editor_radios('pay_limit', __('购买权限', 'aksrt'), array_map('strval', $this->pay_limit_labels()));
|
||||
echo '</div>';
|
||||
$this->editor_section_close();
|
||||
|
||||
echo '<div data-aksrt-product-pane="enabled">';
|
||||
$this->editor_section_open(__('价格', 'aksrt'), __('会员价填 0 表示该等级免费,且不能高于售价。', 'aksrt'));
|
||||
echo '<fieldset class="aksrt-native-form-grid aksrt-product-pane" data-aksrt-product-pane="cash"><legend class="screen-reader-text">' . esc_html__('现金价格', 'aksrt') . '</legend>';
|
||||
$this->editor_number('price', __('执行价', 'aksrt'), $currency, '0.01', 'data-aksrt-product-base="cash"');
|
||||
$this->editor_number('original_price', __('原价', 'aksrt'), $currency, '0.01', '', __('高于执行价时划线显示。', 'aksrt'));
|
||||
$this->editor_number('vip1_price', __('VIP1 价格', 'aksrt'), $currency, '0.01', 'data-aksrt-product-vip="cash"');
|
||||
$this->editor_number('vip2_price', __('VIP2 价格', 'aksrt'), $currency, '0.01', 'data-aksrt-product-vip="cash"');
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('促销标签', 'aksrt') . '</span>'
|
||||
. '<input type="text" name="aksrt_pay[promotion_tag]"><small class="aksrt-native-field__hint">' . esc_html__('显示在原价上方,支持安全 HTML,例如图标。', 'aksrt') . '</small></label>';
|
||||
echo '</fieldset>';
|
||||
echo '<fieldset class="aksrt-native-form-grid aksrt-product-pane" data-aksrt-product-pane="points" hidden disabled><legend class="screen-reader-text">' . esc_html__('积分价格', 'aksrt') . '</legend>';
|
||||
$this->editor_number('points_price', __('积分售价', 'aksrt'), __('积分', 'aksrt'), '1', 'data-aksrt-product-base="points"');
|
||||
$this->editor_number('vip1_points', __('VIP1 积分价', 'aksrt'), __('积分', 'aksrt'), '1', 'data-aksrt-product-vip="points"');
|
||||
$this->editor_number('vip2_points', __('VIP2 积分价', 'aksrt'), __('积分', 'aksrt'), '1', 'data-aksrt-product-vip="points"');
|
||||
echo '</fieldset>';
|
||||
$this->editor_section_close();
|
||||
|
||||
$this->editor_section_open(__('销售', 'aksrt'));
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
$this->editor_number('pay_count', __('销量浮动', 'aksrt'), '', '1', '', __('在真实销量上增加或减少的数量,可填负数。', 'aksrt'), false);
|
||||
echo '<div class="aksrt-native-field aksrt-product-switches">';
|
||||
$this->editor_checkbox('show_sales', __('前台显示销量', 'aksrt'));
|
||||
$this->editor_checkbox('coupon_s', __('允许使用优惠码', 'aksrt'));
|
||||
$this->editor_checkbox('order_expired_s', __('启用订单有效期', 'aksrt'));
|
||||
echo '</div></div>';
|
||||
echo '<fieldset class="aksrt-product-pane aksrt-product-expire" data-aksrt-product-pane="expire" hidden disabled><legend class="screen-reader-text">' . esc_html__('订单有效期', 'aksrt') . '</legend>';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('有效时长', 'aksrt') . '</span><span class="aksrt-product-inline">'
|
||||
. '<input type="number" name="aksrt_pay[order_expired_time]" min="1" step="1" required>'
|
||||
. '<select name="aksrt_pay[order_expired_unit]" aria-label="' . esc_attr__('时长单位', 'aksrt') . '">'
|
||||
. '<option value="day">' . esc_html__('天', 'aksrt') . '</option><option value="hour">' . esc_html__('小时', 'aksrt') . '</option><option value="minute">' . esc_html__('分钟', 'aksrt') . '</option>'
|
||||
. '</select></span><small class="aksrt-native-field__hint">' . esc_html__('购买后仅在有效期内可查看,过期需再次购买。', 'aksrt') . '</small></label></fieldset>';
|
||||
$this->editor_section_close();
|
||||
|
||||
$this->editor_section_open(__('商品文案', 'aksrt'));
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('商品标题', 'aksrt') . '</span><input type="text" name="aksrt_pay[pay_title]" maxlength="200">'
|
||||
. '<small class="aksrt-native-field__hint">' . esc_html__('留空则使用文章标题。', 'aksrt') . '</small></label>';
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('商品简介', 'aksrt') . '</span><textarea name="aksrt_pay[pay_doc]" rows="2"></textarea></label>';
|
||||
echo '</div>';
|
||||
$this->editor_section_close();
|
||||
echo '</div>';
|
||||
|
||||
echo '<p class="aksrt-native-callout aksrt-product-note">' . esc_html__('下载资源、付费图片、视频剧集和隐藏内容请在文章编辑页的「付费功能」面板维护。', 'aksrt')
|
||||
. ' <a href="#" target="_blank" rel="noopener noreferrer" data-aksrt-product-edit-link>' . esc_html__('打开文章编辑页', 'aksrt') . '</a></p>';
|
||||
|
||||
echo '</div><footer class="aksrt-product-dialog__foot">';
|
||||
echo '<button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
echo '<button type="submit" class="button button-primary" data-aksrt-product-submit>' . esc_html__('保存设置', 'aksrt') . '</button>';
|
||||
echo '</footer></form></dialog>';
|
||||
}
|
||||
|
||||
private function editor_section_open(string $title, string $hint = ''): void {
|
||||
echo '<section class="aksrt-product-section"><h3>' . esc_html($title) . '</h3>';
|
||||
if ($hint !== '') {
|
||||
echo '<p class="aksrt-product-section__hint">' . esc_html($hint) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
private function editor_section_close(): void {
|
||||
echo '</section>';
|
||||
}
|
||||
|
||||
/** @param array<string, string> $options */
|
||||
private function editor_radios(string $key, string $title, array $options, string $pane = ''): void {
|
||||
echo '<fieldset class="aksrt-native-field aksrt-product-radios"' . ($pane !== '' ? ' data-aksrt-product-pane="' . esc_attr($pane) . '"' : '') . '>';
|
||||
echo '<legend class="aksrt-native-field__label">' . esc_html($title) . '</legend><div>';
|
||||
foreach ($options as $value => $label) {
|
||||
echo '<label><input type="radio" name="aksrt_pay[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '"><span>' . esc_html($label) . '</span></label>';
|
||||
}
|
||||
echo '</div></fieldset>';
|
||||
}
|
||||
|
||||
private function editor_number(string $key, string $title, string $unit, string $step, string $attributes = '', string $hint = '', bool $non_negative = true): void {
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html($title) . '</span><span class="aksrt-product-inline">';
|
||||
echo '<input type="number" name="aksrt_pay[' . esc_attr($key) . ']" step="' . esc_attr($step) . '"' . ($non_negative ? ' min="0"' : '') . ' required ' . $attributes . '>';
|
||||
if ($unit !== '') {
|
||||
echo '<span class="aksrt-product-unit">' . esc_html($unit) . '</span>';
|
||||
}
|
||||
echo '</span>';
|
||||
if ($hint !== '') {
|
||||
echo '<small class="aksrt-native-field__hint">' . esc_html($hint) . '</small>';
|
||||
}
|
||||
echo '</label>';
|
||||
}
|
||||
|
||||
private function editor_checkbox(string $key, string $label): void {
|
||||
echo '<label class="aksrt-product-check"><input type="checkbox" name="aksrt_pay[' . esc_attr($key) . ']" value="1">' . esc_html($label) . '</label>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 保存
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function handle_save(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限执行此操作。', 'aksrt'), '', ['response' => 403]);
|
||||
}
|
||||
check_admin_referer(self::SAVE_ACTION);
|
||||
|
||||
$post_id = absint($_POST['post_id'] ?? 0);
|
||||
$context = $this->posted_context();
|
||||
$post = get_post($post_id);
|
||||
if (!$post instanceof \WP_Post || $post->post_type !== 'post' || !current_user_can('edit_post', $post_id)) {
|
||||
$this->redirect_after_save($context, 'product_missing', 0);
|
||||
}
|
||||
|
||||
$raw = isset($_POST['aksrt_pay']) && is_array($_POST['aksrt_pay']) ? wp_unslash($_POST['aksrt_pay']) : [];
|
||||
$meta = aksrt_get_pay_meta($post);
|
||||
|
||||
$pay_type = DomainModel::normalize_pay_type((string) ($raw['pay_type'] ?? ''));
|
||||
if ($pay_type === DomainModel::PAY_TYPE_NO) {
|
||||
$meta['pay_type'] = DomainModel::PAY_TYPE_NO;
|
||||
DomainModel::persist_pay_meta($post_id, $meta);
|
||||
$this->redirect_after_save($context, 'product_disabled', $post_id);
|
||||
}
|
||||
|
||||
$code = $this->apply_quick_fields($meta, $raw, $pay_type);
|
||||
if ($code !== '') {
|
||||
$this->redirect_after_save($context, $code, $post_id);
|
||||
}
|
||||
DomainModel::persist_pay_meta($post_id, $meta);
|
||||
$this->redirect_after_save($context, 'product_saved', $post_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把提交值写进 $meta。只校验/改写当前支付方式的价格字段,另一种方式的价格原样保留。
|
||||
*
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $raw
|
||||
* @return string 失败时的 flash 码,成功为空串。
|
||||
*/
|
||||
private function apply_quick_fields(array &$meta, array $raw, string $pay_type): string {
|
||||
$meta['pay_type'] = $pay_type;
|
||||
$limit = (int) ($raw['pay_limit'] ?? 0);
|
||||
$meta['pay_limit'] = isset($this->pay_limit_labels()[$limit]) ? $limit : 0;
|
||||
$meta['pay_modo'] = ($raw['pay_modo'] ?? '0') === 'points' ? 'points' : '0';
|
||||
|
||||
if ($meta['pay_modo'] === 'points') {
|
||||
$amounts = [];
|
||||
foreach (self::POINT_FIELDS as $key) {
|
||||
$value = trim((string) ($raw[$key] ?? ''));
|
||||
if (!preg_match('/^\d{1,9}$/', $value)) {
|
||||
return 'product_invalid_amount';
|
||||
}
|
||||
$amounts[$key] = (int) $value;
|
||||
}
|
||||
if ($amounts['vip1_points'] > $amounts['points_price'] || $amounts['vip2_points'] > $amounts['points_price']) {
|
||||
return 'product_vip_price';
|
||||
}
|
||||
$meta = array_merge($meta, $amounts);
|
||||
} else {
|
||||
$minor = [];
|
||||
foreach (self::CASH_FIELDS as $key) {
|
||||
try {
|
||||
$minor[$key] = AssetAmount::balance_minor((string) ($raw[$key] ?? ''));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 'product_invalid_amount';
|
||||
}
|
||||
}
|
||||
if ($minor['vip1_price'] > $minor['price'] || $minor['vip2_price'] > $minor['price']) {
|
||||
return 'product_vip_price';
|
||||
}
|
||||
foreach ($minor as $key => $value) {
|
||||
$meta[$key] = AssetAmount::decimal_from_minor($value);
|
||||
}
|
||||
$meta['promotion_tag'] = (string) ($raw['promotion_tag'] ?? '');
|
||||
}
|
||||
|
||||
$pay_count = trim((string) ($raw['pay_count'] ?? '0'));
|
||||
if (!preg_match('/^-?\d{1,9}$/', $pay_count)) {
|
||||
return 'product_invalid_amount';
|
||||
}
|
||||
$meta['pay_count'] = (int) $pay_count;
|
||||
$meta['show_sales'] = !empty($raw['show_sales']);
|
||||
$meta['coupon_s'] = !empty($raw['coupon_s']);
|
||||
$meta['order_expired_s'] = !empty($raw['order_expired_s']);
|
||||
if ($meta['order_expired_s']) {
|
||||
$meta['order_expired_time'] = max(1, (int) ($raw['order_expired_time'] ?? 1));
|
||||
$meta['order_expired_unit'] = (string) ($raw['order_expired_unit'] ?? 'day');
|
||||
}
|
||||
$meta['pay_title'] = (string) ($raw['pay_title'] ?? '');
|
||||
$meta['pay_doc'] = (string) ($raw['pay_doc'] ?? '');
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @return array<string, string|int> */
|
||||
private function posted_context(): array {
|
||||
$raw = isset($_POST['ctx']) && is_array($_POST['ctx']) ? wp_unslash($_POST['ctx']) : [];
|
||||
$context = [];
|
||||
$pay_type = sanitize_key((string) ($raw['pay_type'] ?? ''));
|
||||
if (isset($this->paid_type_labels()[$pay_type])) {
|
||||
$context['pay_type'] = $pay_type;
|
||||
}
|
||||
$search = sanitize_text_field((string) ($raw['s'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$context['s'] = $search;
|
||||
}
|
||||
$paged = absint($raw['paged'] ?? 0);
|
||||
if ($paged > 1) {
|
||||
$context['paged'] = $paged;
|
||||
}
|
||||
return $context;
|
||||
}
|
||||
|
||||
/** @param array<string, string|int> $context */
|
||||
private function redirect_after_save(array $context, string $code, int $post_id): void {
|
||||
$args = $context + ['aksrt_pay_msg' => $code];
|
||||
if ($post_id > 0) {
|
||||
$args['product'] = $post_id;
|
||||
}
|
||||
wp_safe_redirect($this->page_url(self::PAGE_SLUG, $args));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
/**
|
||||
* 付费文章:列表视图(类型视图 + 搜索 + 商品表 + 分页)。
|
||||
*
|
||||
* 作为 flat sibling trait 被 PayProducts 使用(PSR-4:trait 必须与宿主类同目录)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Core\DomainModel;
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Payment\OrderRepository;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait PayProductsList {
|
||||
|
||||
/**
|
||||
* 渲染列表卡片。
|
||||
*
|
||||
* @return array<string, string|int> 当前列表上下文(筛选 + 页码),快速设置保存后据此回到原位置。
|
||||
*/
|
||||
private function render_product_list(): array {
|
||||
$type_labels = $this->paid_type_labels();
|
||||
$pay_type = isset($_GET['pay_type']) ? sanitize_key((string) wp_unslash($_GET['pay_type'])) : '';
|
||||
if (!isset($type_labels[$pay_type])) {
|
||||
$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;
|
||||
|
||||
$query = $this->query_paid_posts($pay_type, $search, $page_num, $per_page);
|
||||
$total = $query['total'];
|
||||
$pages = max(1, (int) ceil($total / $per_page));
|
||||
|
||||
// 页码越界(手工改 URL、或保存后文章移出列表)会停在空页上:回退到最后一页重查一次。
|
||||
if ($page_num > $pages) {
|
||||
$page_num = $pages;
|
||||
$query = $this->query_paid_posts($pay_type, $search, $page_num, $per_page);
|
||||
}
|
||||
$posts = $query['posts'];
|
||||
|
||||
$sales_map = [];
|
||||
if ($posts) {
|
||||
$sales_map = OrderRepository::get_instance()->get_content_sales_map(
|
||||
array_map(static fn(\WP_Post $post): int => (int) $post->ID, $posts),
|
||||
DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
);
|
||||
}
|
||||
|
||||
// 翻页链接显式带上当前筛选条件(不含 paged)。
|
||||
$query_args = array_filter(['pay_type' => $pay_type, 's' => $search], static fn(string $value): bool => $value !== '');
|
||||
|
||||
NativeUi::card_open('', '', true, 'aksrt-products-card');
|
||||
$this->render_product_toolbar($pay_type, $search, $type_labels);
|
||||
echo '<div class="aksrt-native-table-wrap">';
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-products-table"><thead><tr>';
|
||||
echo '<th scope="col" class="column-product">' . esc_html__('商品', 'aksrt') . '</th>';
|
||||
echo '<th scope="col" class="column-type">' . esc_html__('付费类型', 'aksrt') . '</th>';
|
||||
echo '<th scope="col" class="column-price">' . esc_html__('价格', 'aksrt') . '</th>';
|
||||
echo '<th scope="col" class="column-sales">' . esc_html__('销量', 'aksrt') . '</th>';
|
||||
echo '<th scope="col" class="column-revenue">' . esc_html__('销售额', 'aksrt') . '</th>';
|
||||
echo '<th scope="col" class="column-actions">' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if (!$posts) {
|
||||
echo NativeUi::empty_row(
|
||||
6,
|
||||
__('暂无付费文章', 'aksrt'),
|
||||
$query_args !== []
|
||||
? __('调整类型或搜索条件后重试。', 'aksrt')
|
||||
: __('在文章编辑页的「付费功能」面板开启付费后,文章会出现在这里。', 'aksrt')
|
||||
);
|
||||
}
|
||||
|
||||
$currency = $this->currency_symbol();
|
||||
foreach ($posts as $post) {
|
||||
$this->render_product_row($post, $sales_map[(int) $post->ID] ?? [], $type_labels, $currency);
|
||||
}
|
||||
|
||||
echo '</tbody></table>';
|
||||
NativeUi::table_wrap_close();
|
||||
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
|
||||
NativeUi::card_close();
|
||||
|
||||
return $query_args + ($page_num > 1 ? ['paged' => $page_num] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费文章 = `_aksrt_pay_type` 为任一付费类型;所有写入口都同步这个派生键,直接按它查询分页。
|
||||
*
|
||||
* @return array{posts: \WP_Post[], total: int}
|
||||
*/
|
||||
private function query_paid_posts(string $pay_type, string $search, int $page, int $per_page): array {
|
||||
$args = [
|
||||
'post_type' => 'post',
|
||||
'post_status' => self::LIST_STATUSES,
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => $page,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'ignore_sticky_posts' => true,
|
||||
'update_post_term_cache' => false,
|
||||
'meta_query' => [[
|
||||
'key' => DomainModel::PAY_TYPE_KEY,
|
||||
'value' => $pay_type !== '' ? [$pay_type] : array_keys($this->paid_type_labels()),
|
||||
'compare' => 'IN',
|
||||
]],
|
||||
];
|
||||
if ($search !== '') {
|
||||
if (ctype_digit($search)) {
|
||||
$args['post__in'] = [(int) $search];
|
||||
} else {
|
||||
$args['s'] = $search;
|
||||
}
|
||||
}
|
||||
|
||||
$query = new \WP_Query($args);
|
||||
return [
|
||||
'posts' => array_values(array_filter($query->posts, static fn($post): bool => $post instanceof \WP_Post)),
|
||||
'total' => (int) $query->found_posts,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, int> 各付费类型的文章数(不受搜索词影响,与 WP 原生视图一致)。 */
|
||||
private function paid_type_counts(): array {
|
||||
global $wpdb;
|
||||
$types = array_keys($this->paid_type_labels());
|
||||
$statuses = self::LIST_STATUSES;
|
||||
$sql = "SELECT pm.meta_value AS pay_type, COUNT(DISTINCT p.ID) AS total
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE pm.meta_key = %s
|
||||
AND p.post_type = 'post'
|
||||
AND p.post_status IN (" . implode(', ', array_fill(0, count($statuses), '%s')) . ')
|
||||
AND pm.meta_value IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')
|
||||
GROUP BY pm.meta_value';
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge([DomainModel::PAY_TYPE_KEY], $statuses, $types)), ARRAY_A);
|
||||
|
||||
$counts = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$counts[(string) $row['pay_type']] = (int) $row['total'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** @param array<string, string> $type_labels */
|
||||
private function render_product_toolbar(string $pay_type, string $search, array $type_labels): void {
|
||||
$counts = $this->paid_type_counts();
|
||||
$keep = $search !== '' ? ['s' => $search] : [];
|
||||
|
||||
echo '<div class="aksrt-products-toolbar">';
|
||||
echo '<ul class="subsubsub" aria-label="' . esc_attr__('付费类型筛选', 'aksrt') . '">';
|
||||
foreach (['' => __('全部', 'aksrt')] + $type_labels as $key => $label) {
|
||||
$key = (string) $key;
|
||||
$count = $key === '' ? array_sum($counts) : (int) ($counts[$key] ?? 0);
|
||||
$url = $this->page_url(self::PAGE_SLUG, $key === '' ? $keep : $keep + ['pay_type' => $key]);
|
||||
echo '<li><a href="' . esc_url($url) . '"' . ($key === $pay_type ? ' class="current" aria-current="page"' : '') . '>'
|
||||
. esc_html($label) . ' <span class="count">' . esc_html(number_format_i18n($count)) . '</span></a></li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
|
||||
echo '<form method="get" action="' . esc_url(admin_url('admin.php')) . '" class="aksrt-native-filters" role="search">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '">';
|
||||
if ($pay_type !== '') {
|
||||
echo '<input type="hidden" name="pay_type" value="' . esc_attr($pay_type) . '">';
|
||||
}
|
||||
echo '<label class="screen-reader-text" for="aksrt-products-search">' . esc_html__('搜索标题或文章 ID', 'aksrt') . '</label>';
|
||||
echo '<input type="search" id="aksrt-products-search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('搜索标题或文章 ID', 'aksrt') . '">';
|
||||
echo '<button type="submit" class="button">' . esc_html__('搜索', 'aksrt') . '</button>';
|
||||
if ($search !== '') {
|
||||
$clear = $this->page_url(self::PAGE_SLUG, $pay_type !== '' ? ['pay_type' => $pay_type] : []);
|
||||
echo '<a class="button-link" href="' . esc_url($clear) . '">' . esc_html__('清除搜索', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sales
|
||||
* @param array<string, string> $type_labels
|
||||
*/
|
||||
private function render_product_row(\WP_Post $post, array $sales, array $type_labels, string $currency): void {
|
||||
$post_id = (int) $post->ID;
|
||||
$meta = aksrt_get_pay_meta($post);
|
||||
$pay_type = DomainModel::normalize_pay_type((string) $meta['pay_type']);
|
||||
$title = get_the_title($post);
|
||||
if ($title === '') {
|
||||
$title = sprintf(__('(无标题 #%d)', 'aksrt'), $post_id);
|
||||
}
|
||||
$edit_url = (string) get_edit_post_link($post_id, 'raw');
|
||||
$permalink = (string) get_permalink($post_id);
|
||||
$orders = (int) ($sales['sales_count'] ?? 0);
|
||||
$highlight = (int) ($_GET['product'] ?? 0) === $post_id;
|
||||
|
||||
echo '<tr' . ($highlight ? ' class="is-highlight"' : '') . '>';
|
||||
|
||||
echo '<td class="column-product"><strong>';
|
||||
echo $edit_url !== ''
|
||||
? '<a class="row-title" href="' . esc_url($edit_url) . '">' . esc_html($title) . '</a>'
|
||||
: esc_html($title);
|
||||
echo '</strong><div class="aksrt-products-sub">#' . esc_html((string) $post_id);
|
||||
if ($post->post_status !== 'publish') {
|
||||
$status = get_post_status_object($post->post_status);
|
||||
echo ' · ' . esc_html($status ? (string) $status->label : $post->post_status);
|
||||
}
|
||||
if ((string) $meta['pay_title'] !== '') {
|
||||
echo ' · ' . esc_html(sprintf(__('商品标题:%s', 'aksrt'), (string) $meta['pay_title']));
|
||||
}
|
||||
echo '</div></td>';
|
||||
|
||||
echo '<td class="column-type">' . NativeUi::badge($type_labels[$pay_type] ?? $pay_type, self::TYPE_TONES[$pay_type] ?? 'neutral');
|
||||
$limit = (int) $meta['pay_limit'];
|
||||
if ($limit > 0) {
|
||||
echo '<div class="aksrt-products-sub">' . esc_html($this->pay_limit_labels()[$limit] ?? '') . '</div>';
|
||||
}
|
||||
echo '</td>';
|
||||
|
||||
echo '<td class="column-price">' . $this->price_cell_html($meta, $currency) . '</td>';
|
||||
|
||||
echo '<td class="column-sales"><span class="aksrt-native-amount">' . esc_html(sprintf(__('%d 单', 'aksrt'), $orders)) . '</span>';
|
||||
echo '<div class="aksrt-products-sub">' . esc_html(!empty($meta['show_sales'])
|
||||
? sprintf(__('前台显示 %d', 'aksrt'), aksrt_get_content_sales_count($post, $meta))
|
||||
: __('前台不显示销量', 'aksrt')) . '</div></td>';
|
||||
|
||||
echo '<td class="column-revenue"><span class="aksrt-native-amount">' . esc_html($this->sales_amount($sales['revenue'] ?? '0.00', $currency)) . '</span>';
|
||||
if ($orders > 0) {
|
||||
echo '<div class="aksrt-products-sub">' . esc_html(sprintf(__('均价 %s', 'aksrt'), $this->sales_amount($sales['avg_amount'] ?? '0.00', $currency))) . '</div>';
|
||||
}
|
||||
echo '</td>';
|
||||
|
||||
$payload = $this->product_payload($post, $meta, $edit_url);
|
||||
echo '<td class="column-actions">';
|
||||
echo '<button type="button" class="button button-small button-primary" data-aksrt-product-edit="' . esc_attr((string) wp_json_encode($payload)) . '" aria-haspopup="dialog" aria-controls="aksrt-product-editor">'
|
||||
. esc_html__('快速设置', 'aksrt') . '</button>';
|
||||
echo '<div class="aksrt-products-links">';
|
||||
if ($edit_url !== '') {
|
||||
echo '<a href="' . esc_url($edit_url) . '">' . esc_html__('编辑文章', 'aksrt') . '</a>';
|
||||
}
|
||||
$orders_url = $this->page_url('aksrt_orders', [
|
||||
'product_type' => DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
]);
|
||||
echo '<a href="' . esc_url($orders_url) . '">' . esc_html__('订单', 'aksrt') . '</a>';
|
||||
if ($permalink !== '') {
|
||||
echo '<a href="' . esc_url($permalink) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('查看', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</div></td></tr>';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $meta */
|
||||
private function price_cell_html(array $meta, string $currency): string {
|
||||
$free = __('免费', 'aksrt');
|
||||
if ($meta['pay_modo'] === 'points') {
|
||||
$fmt = static fn(int $points): string => $points > 0 ? sprintf(__('%d 积分', 'aksrt'), $points) : $free;
|
||||
$main = $fmt((int) $meta['points_price']);
|
||||
$original = '';
|
||||
$vip = [$fmt((int) $meta['vip1_points']), $fmt((int) $meta['vip2_points'])];
|
||||
} else {
|
||||
$fmt = static fn(string $decimal): string => AssetAmount::balance_minor($decimal) > 0 ? $currency . $decimal : $free;
|
||||
$main = $fmt((string) $meta['price']);
|
||||
$original = AssetAmount::balance_minor((string) $meta['original_price']) > AssetAmount::balance_minor((string) $meta['price'])
|
||||
? $currency . $meta['original_price']
|
||||
: '';
|
||||
$vip = [$fmt((string) $meta['vip1_price']), $fmt((string) $meta['vip2_price'])];
|
||||
}
|
||||
|
||||
return '<span class="aksrt-native-amount">' . esc_html($main) . '</span>'
|
||||
. ($original !== '' ? ' <del class="aksrt-native-muted">' . esc_html($original) . '</del>' : '')
|
||||
. '<div class="aksrt-products-sub">' . esc_html(sprintf(__('VIP1 %1$s · VIP2 %2$s', 'aksrt'), $vip[0], $vip[1])) . '</div>';
|
||||
}
|
||||
|
||||
/** 订单统计里积分订单为 int、现金订单为两位小数字符串。 */
|
||||
private function sales_amount($amount, string $currency): string {
|
||||
return is_int($amount)
|
||||
? sprintf(__('%d 积分', 'aksrt'), $amount)
|
||||
: $currency . $this->cash_decimal((string) $amount);
|
||||
}
|
||||
}
|
||||
@@ -612,13 +612,7 @@ class PostListEnhance {
|
||||
}
|
||||
}
|
||||
|
||||
$pay_meta = \AKSRT\Core\DomainModel::sanitize_pay_meta($pay_meta);
|
||||
update_post_meta($post_id, \AKSRT\Core\DomainModel::PAY_META_KEY, $pay_meta);
|
||||
update_post_meta($post_id, \AKSRT\Core\DomainModel::PAY_TYPE_KEY, (string) $pay_meta['pay_type']);
|
||||
update_post_meta($post_id, \AKSRT\Core\DomainModel::PAY_PRICE_KEY, $pay_meta['pay_modo'] === 'points' ? (int) $pay_meta['points_price'] : (string) $pay_meta['price']);
|
||||
update_post_meta($post_id, '_aksrt_pay_modo', (string) $pay_meta['pay_modo']);
|
||||
update_post_meta($post_id, '_aksrt_pay_limit', (int) $pay_meta['pay_limit']);
|
||||
update_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_MARKER_KEY, \AKSRT\Core\DomainModel::is_resource_pay_type((string) $pay_meta['pay_type']) ? 1 : 0);
|
||||
\AKSRT\Core\DomainModel::persist_pay_meta($post_id, $pay_meta);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -226,6 +226,21 @@ class DomainModel {
|
||||
return self::normalize_pay_type($pay_type) === self::PAY_TYPE_DOWNLOAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费 meta 的唯一写入口:清洗后写入主 bag,并同步可查询的派生键(类型 / 价格 / 资源标记)。
|
||||
*
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed> 实际写入的清洗结果。
|
||||
*/
|
||||
public static function persist_pay_meta(int $post_id, array $meta): array {
|
||||
$meta = self::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, self::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, self::PAY_TYPE_KEY, (string) $meta['pay_type']);
|
||||
update_post_meta($post_id, self::PAY_PRICE_KEY, $meta['pay_modo'] === 'points' ? (int) $meta['points_price'] : (string) $meta['price']);
|
||||
update_post_meta($post_id, self::RESOURCE_MARKER_KEY, self::is_resource_pay_type((string) $meta['pay_type']) ? 1 : 0);
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int, array{label:string,url:string,type:string,extract_code:string,copy_key:string,copy_val:string,icon:string,class:string,attachment_id:int}>
|
||||
|
||||
@@ -205,20 +205,6 @@ trait FrontSubmitPay {
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function write_pay_meta(int $post_id, array $meta): void {
|
||||
$meta = DomainModel::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? DomainModel::PAY_TYPE_NO));
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::PAY_PRICE_KEY,
|
||||
($meta['pay_modo'] ?? '0') === 'points'
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00')
|
||||
);
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::RESOURCE_MARKER_KEY,
|
||||
DomainModel::is_resource_pay_type((string) ($meta['pay_type'] ?? 'no')) ? 1 : 0
|
||||
);
|
||||
DomainModel::persist_pay_meta($post_id, $meta);
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ class ResourceMetaBox {
|
||||
}
|
||||
}
|
||||
|
||||
$meta = DomainModel::sanitize_pay_meta([
|
||||
DomainModel::persist_pay_meta($post_id, [
|
||||
'pay_type' => $raw['pay_type'] ?? 'no',
|
||||
'pay_limit' => $raw['pay_limit'] ?? 0,
|
||||
'pay_modo' => $raw['pay_modo'] ?? '0',
|
||||
@@ -343,12 +343,5 @@ class ResourceMetaBox {
|
||||
'pay_details' => (string) ($raw['pay_details'] ?? ''),
|
||||
'pay_extra_hide' => (string) ($raw['pay_extra_hide'] ?? ''),
|
||||
]);
|
||||
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) $meta['pay_type']);
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, $meta['pay_modo'] === 'points' ? (int) $meta['points_price'] : (string) $meta['price']);
|
||||
update_post_meta($post_id, '_aksrt_pay_modo', (string) $meta['pay_modo']);
|
||||
update_post_meta($post_id, '_aksrt_pay_limit', (int) $meta['pay_limit']);
|
||||
update_post_meta($post_id, DomainModel::RESOURCE_MARKER_KEY, DomainModel::is_resource_pay_type((string) $meta['pay_type']) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user