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:
kate522andClaude Fable 5 committed 2026-10-31 17:28:45 +08:00
1 parent 4083613dbe
commit dc6dbc05d8
11 files changed
+983 -349

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
/* 付费文章(aksrt_pay_products):列表 + 快速设置弹窗。复用 native-ui.css 的 --an-* 令牌,规则限定在本页类名下。 */
.aksrt-products-page [hidden],
.aksrt-product-dialog [hidden] { display: none !important; }
/* ---------- 工具栏:类型视图 + 搜索 ---------- */
.aksrt-products-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; padding: 14px 20px; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-products-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; }
.aksrt-products-toolbar .aksrt-native-filters { margin: 0; }
/* ---------- 商品表 ---------- */
.aksrt-products-page .aksrt-products-table { min-width: 960px; table-layout: fixed; }
.aksrt-products-table .column-product { width: 32%; }
.aksrt-products-table .column-type { width: 13%; }
.aksrt-products-table .column-price { width: 17%; }
.aksrt-products-table .column-sales { width: 11%; }
.aksrt-products-table .column-revenue { width: 12%; }
.aksrt-products-table .column-actions { width: 15%; }
.aksrt-products-page .aksrt-products-table td { vertical-align: top; overflow-wrap: anywhere; }
.aksrt-products-table .row-title { font-weight: 600; }
.aksrt-products-sub { margin-top: 4px; color: var(--an-muted); font-size: 12px; line-height: 1.5; }
.aksrt-products-links { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; font-size: 12px; }
.aksrt-products-links a { text-decoration: none; }
.aksrt-products-table tr.is-highlight > td { background: var(--an-primary-soft); }
/* ---------- 快速设置弹窗 ---------- */
html.aksrt-products-modal-open { overflow: hidden; }
.aksrt-product-dialog { width: min(760px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); margin: auto; padding: 0; overflow: hidden; color: var(--an-text-2); background: var(--an-card); border: 0; border-radius: var(--an-radius); box-shadow: 0 16px 64px rgba(15, 23, 42, .2); }
.aksrt-product-dialog::backdrop { background: rgba(15, 23, 42, .45); }
.aksrt-product-dialog__frame { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
.aksrt-product-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px 18px; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-product-dialog .aksrt-product-dialog__head h2 { margin: 0; padding: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.5; }
.aksrt-product-dialog__head p { margin: 5px 0 0; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.aksrt-product-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; color: var(--an-text-2); background: transparent; border: 0; border-radius: var(--an-radius-sm); cursor: pointer; }
.aksrt-product-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); }
.aksrt-product-dialog__body { min-height: 0; padding: 4px 24px 20px; overflow-y: auto; overscroll-behavior: contain; }
.aksrt-product-dialog__foot { display: flex; flex: none; justify-content: flex-end; gap: 8px; padding: 14px 24px; background: var(--an-card); border-top: 1px solid var(--an-border-soft); }
.aksrt-product-dialog__foot .button { min-width: 110px; }
.aksrt-product-dialog .button:disabled { opacity: .65; cursor: not-allowed; }
.aksrt-product-dialog :is(a, button, input, select, textarea):focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-product-section { padding: 18px 0; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-product-section:last-child { border-bottom: 0; }
.aksrt-product-dialog .aksrt-product-section h3 { margin: 0 0 12px; color: var(--an-text); font-size: 14px; font-weight: 600; line-height: 1.5; }
.aksrt-product-section__hint { margin: -6px 0 12px; color: var(--an-muted); font-size: 12px; }
.aksrt-product-section .aksrt-native-callout { margin: 14px 0 0; }
.aksrt-product-dialog .aksrt-native-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.aksrt-product-pane { min-width: 0; margin: 0; padding: 0; border: 0; }
.aksrt-product-pane + .aksrt-product-pane { margin-top: 0; }
.aksrt-product-dialog textarea { resize: vertical; }
.aksrt-product-radios { margin: 0; padding: 0; border: 0; }
.aksrt-product-radios legend { margin-bottom: 6px; padding: 0; }
.aksrt-product-radios > div { display: flex; flex-wrap: wrap; gap: 8px; }
.aksrt-product-radios label { position: relative; cursor: pointer; }
.aksrt-product-radios input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); }
.aksrt-product-radios label > span { display: inline-flex; align-items: center; min-height: 32px; padding: 4px 12px; color: var(--an-text-2); background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); }
.aksrt-product-radios label:hover > span { border-color: var(--an-primary); }
.aksrt-product-radios input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; }
.aksrt-product-radios input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-product-section > [data-aksrt-product-pane="enabled"] { margin-top: 14px; }
.aksrt-product-inline { display: flex; align-items: center; gap: 6px; }
.aksrt-product-inline input { flex: 1 1 auto; min-width: 0; }
.aksrt-product-inline select { flex: none; width: auto; }
.aksrt-product-unit { flex: none; color: var(--an-muted); }
.aksrt-product-switches { justify-content: flex-end; gap: 8px; }
.aksrt-product-check { display: inline-flex; align-items: center; gap: 6px; color: var(--an-text); }
.aksrt-product-check input { margin: 0; }
.aksrt-product-expire { max-width: 50%; margin-top: 14px; }
.aksrt-product-note { margin: 4px 0 0; font-size: 12px; }
@media (max-width: 782px) {
.aksrt-products-toolbar { padding: 12px 14px; }
.aksrt-products-toolbar .aksrt-native-filters { width: 100%; }
}
@media (max-width: 600px) {
.aksrt-product-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
.aksrt-product-dialog__head,
.aksrt-product-dialog__body,
.aksrt-product-dialog__foot { padding-left: 16px; padding-right: 16px; }
.aksrt-product-dialog .aksrt-native-form-grid { grid-template-columns: 1fr; }
.aksrt-product-expire { max-width: none; }
.aksrt-product-dialog__foot .button { flex: 1; min-width: 0; }
}
+196
View File
@@ -0,0 +1,196 @@
/** 付费文章:快速设置弹窗(填充、条件显隐、会员价校验、未保存提醒、防重复提交)。仅本页入队。 */
(function () {
'use strict';
var root = document.querySelector('.aksrt-products-page');
var dialog = document.getElementById('aksrt-product-editor');
if (!root || !dialog || typeof dialog.showModal !== 'function') return;
var form = dialog.querySelector('[data-aksrt-product-form]');
var submit = form.querySelector('[data-aksrt-product-submit]');
var subject = form.querySelector('[data-aksrt-product-subject]');
var editLink = form.querySelector('[data-aksrt-product-edit-link]');
var submitLabel = submit.textContent;
var opener = null;
var snapshot = '';
var pointerStartedOutside = false;
function field(key) {
return form.querySelectorAll('[name="aksrt_pay[' + key + ']"]');
}
function value(key) {
var nodes = field(key);
for (var i = 0; i < nodes.length; i += 1) {
var node = nodes[i];
if (node.type === 'radio') {
if (node.checked) return node.value;
} else if (node.type === 'checkbox') {
return node.checked ? node.value : '';
} else {
return node.value;
}
}
return '';
}
function paneActive(name) {
switch (name) {
case 'enabled': return value('pay_type') !== 'no';
case 'disabled': return value('pay_type') === 'no';
case 'cash': return value('pay_modo') !== 'points';
case 'points': return value('pay_modo') === 'points';
case 'expire': return value('order_expired_s') === '1';
default: return true;
}
}
/** 隐藏的分区连同其中的控件一起禁用:既不提交,也不参与必填校验。 */
function syncPanes() {
form.querySelectorAll('[data-aksrt-product-pane]').forEach(function (pane) {
var active = paneActive(pane.getAttribute('data-aksrt-product-pane'));
pane.hidden = !active;
if (pane.tagName === 'FIELDSET') pane.disabled = !active;
});
form.querySelectorAll('input:not([type="hidden"]), select, textarea').forEach(function (control) {
var node = control.parentElement;
var disabled = false;
while (node && node !== form) {
if (node.hasAttribute('data-aksrt-product-pane') && node.hidden) {
disabled = true;
break;
}
node = node.parentElement;
}
control.disabled = disabled;
});
syncVip();
}
/** 现金按「分」比较,避免浮点误差。 */
function amount(raw, mode) {
var text = String(raw || '').trim();
if (mode === 'points') return /^\d+$/.test(text) ? Number(text) : NaN;
var match = text.match(/^(\d+)(?:\.(\d{1,2}))?$/);
return match ? Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0')) : NaN;
}
function syncVip() {
['cash', 'points'].forEach(function (mode) {
var base = form.querySelector('[data-aksrt-product-base="' + mode + '"]');
var message = form.getAttribute(mode === 'points' ? 'data-vip-points-error' : 'data-vip-error');
form.querySelectorAll('[data-aksrt-product-vip="' + mode + '"]').forEach(function (input) {
var vip = amount(input.value, mode);
var limit = amount(base && base.value, mode);
input.setCustomValidity(!input.disabled && vip > limit ? message : '');
});
});
}
function serialize() {
return new URLSearchParams(new FormData(form)).toString();
}
function fill(payload) {
var fields = payload.fields || {};
form.reset();
form.elements.post_id.value = String(payload.id || '');
Object.keys(fields).forEach(function (key) {
var current = String(fields[key]);
field(key).forEach(function (node) {
if (node.type === 'radio') {
node.checked = node.value === current;
} else if (node.type === 'checkbox') {
node.checked = current === '1';
} else {
node.value = key === 'order_expired_time' && current === '0' ? '' : current;
}
});
});
subject.textContent = (payload.title || '') + ' · #' + String(payload.id || '');
editLink.hidden = !payload.editUrl;
editLink.href = payload.editUrl || '#';
syncPanes();
}
function lockScroll() {
document.documentElement.classList.toggle('aksrt-products-modal-open', dialog.open);
}
function requestClose() {
if (form.dataset.submitting) return;
if (serialize() !== snapshot && !window.confirm(form.getAttribute('data-discard'))) return;
dialog.close();
}
root.addEventListener('click', function (event) {
var button = event.target.closest('[data-aksrt-product-edit]');
if (!button || dialog.open) return;
var payload;
try {
payload = JSON.parse(button.getAttribute('data-aksrt-product-edit'));
} catch (error) {
return;
}
opener = button;
fill(payload);
snapshot = serialize();
dialog.showModal();
lockScroll();
});
form.addEventListener('change', syncPanes);
form.addEventListener('input', syncVip);
function isOutside(event) {
var rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
}
dialog.addEventListener('pointerdown', function (event) {
pointerStartedOutside = event.target === dialog && isOutside(event);
});
dialog.addEventListener('click', function (event) {
if (event.target.closest('[data-aksrt-dialog-close]')
|| (pointerStartedOutside && event.target === dialog && isOutside(event))) {
event.preventDefault();
requestClose();
}
pointerStartedOutside = false;
});
dialog.addEventListener('cancel', function (event) {
event.preventDefault();
requestClose();
});
dialog.addEventListener('close', function () {
lockScroll();
if (opener && opener.isConnected) opener.focus();
opener = null;
});
form.addEventListener('submit', function (event) {
syncVip();
if (form.dataset.submitting || !form.checkValidity()) {
event.preventDefault();
form.reportValidity();
return;
}
form.dataset.submitting = '1';
submit.textContent = form.getAttribute('data-busy-label');
// 延后禁用:同步禁用 submit 按钮会让部分浏览器丢掉这次提交。
setTimeout(function () { submit.disabled = true; }, 0);
});
// bfcache 返回时恢复按钮,避免弹窗停在保存中状态。
window.addEventListener('pageshow', function () {
delete form.dataset.submitting;
submit.disabled = false;
submit.textContent = submitLabel;
lockScroll();
});
var highlighted = root.querySelector('.aksrt-products-table tr.is-highlight');
if (highlighted && typeof highlighted.scrollIntoView === 'function') {
highlighted.scrollIntoView({ block: 'center' });
}
})();
+100 -310
View File
@@ -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'));
}
}
+292
View File
@@ -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;
}
}
+281
View File
@@ -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);
}
}
+1 -7
View File
@@ -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);
}
/**
+15
View File
@@ -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}>
+1 -15
View File
@@ -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);
}
}
+1 -8
View File
@@ -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);
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ const chargeCards = [
].map(read).join('\n');
const pages = {
orders: ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
products: read('src/Admin/Native/PayProducts.php'),
products: ['PayProducts', 'PayProductsList', 'PayProductsEditor'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
coupons: read('src/Admin/Native/PayCoupons.php'),
income: read('src/Admin/Native/PayIncome.php'),
withdrawals: read('src/Admin/Native/PayWithdrawals.php'),
@@ -125,14 +125,17 @@ test('pay quick-edit fields map onto the real pay meta schema', () => {
})
test('pay quick-edit save sanitizes through the shared sanitizer', () => {
// 任何批量写入都必须过 sanitize_pay_meta,再回写派生键,与 metabox 保存一致。
assert.match(list, /DomainModel::sanitize_pay_meta/)
assert.match(list, /DomainModel::PAY_META_KEY, \$pay_meta/)
assert.match(list, /DomainModel::PAY_TYPE_KEY/)
assert.match(list, /DomainModel::PAY_PRICE_KEY/)
assert.match(list, /'_aksrt_pay_modo'/)
assert.match(list, /'_aksrt_pay_limit'/)
assert.match(list, /RESOURCE_MARKER_KEY/)
// 任何批量写入都必须走统一写入口 persist_pay_meta:先 sanitize_pay_meta,再回写派生键。
assert.match(list, /DomainModel::persist_pay_meta\(\$post_id, \$pay_meta\)/)
const persist = domain.slice(domain.indexOf('function persist_pay_meta('), domain.indexOf('function sanitize_downloads('))
assert.match(persist, /self::sanitize_pay_meta\(\$meta\)/)
assert.match(persist, /self::PAY_META_KEY, \$meta/)
assert.match(persist, /self::PAY_TYPE_KEY/)
assert.match(persist, /self::PAY_PRICE_KEY/)
assert.match(persist, /self::RESOURCE_MARKER_KEY/)
// _aksrt_pay_modo / _aksrt_pay_limit 没有任何读取方,已删除,不得再写。
assert.doesNotMatch(list, /'_aksrt_pay_modo'|'_aksrt_pay_limit'/)
assert.doesNotMatch(resourceBox, /'_aksrt_pay_modo'|'_aksrt_pay_limit'/)
})
test('pay prices go through the ledger, not raw floats', () => {