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
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user