diff --git a/assets/css/admin/pay-products.css b/assets/css/admin/pay-products.css new file mode 100644 index 00000000..c5911e93 --- /dev/null +++ b/assets/css/admin/pay-products.css @@ -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; } +} diff --git a/assets/js/admin-pay-products.js b/assets/js/admin-pay-products.js new file mode 100644 index 00000000..10f68ec9 --- /dev/null +++ b/assets/js/admin-pay-products.js @@ -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' }); + } +})(); diff --git a/src/Admin/Native/PayProducts.php b/src/Admin/Native/PayProducts.php index 7e8acd50..1243db81 100644 --- a/src/Admin/Native/PayProducts.php +++ b/src/Admin/Native/PayProducts.php @@ -1,16 +1,54 @@ '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 '

' . esc_html__('订单服务不可用。', 'aksrt') . '

'; 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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - - 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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - - // 翻页链接显式带上当前筛选条件(不含 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 '
' . esc_html__('商品', 'aksrt') . '' . esc_html__('ID', 'aksrt') . '' . esc_html__('付费类型', 'aksrt') . '' . esc_html__('模式', 'aksrt') . '' . esc_html__('标价', 'aksrt') . '' . esc_html__('销售均价', 'aksrt') . '' . esc_html__('订单销量', 'aksrt') . '' . esc_html__('Meta 销量', 'aksrt') . '' . esc_html__('销售总额', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
'; - if ($edit_url) { - echo '' . esc_html($title) . ''; - } else { - echo '' . esc_html($title) . ''; - } - echo '' . esc_html((string) $post_id) . '' . esc_html((string) $pt_label) . '' . esc_html($modo_label) . '' . esc_html($price_text) . '' . esc_html($avg) . '' . esc_html((string) $db_sales) . '' . esc_html((string) max(0, $meta_sales)) . '' . esc_html($revenue) . ''; - if ($edit_url) { - echo '' . esc_html__('编辑', 'aksrt') . ''; - } - echo '' . esc_html__('订单', 'aksrt') . ''; - $permalink = get_permalink($post_id); - if ($permalink) { - echo '' . esc_html__('查看', 'aksrt') . ''; - } - echo '
'; - 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 付费类型(不含「关闭」)。 */ + private function paid_type_labels(): array { + $labels = DomainModel::pay_type_labels(); + unset($labels[DomainModel::PAY_TYPE_NO]); + return $labels; + } + /** @return array */ + 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 $type_labels - */ - private function render_filters(string $pay_type, string $search, array $type_labels): void { - echo '
    '; - $base = $this->page_url(self::PAGE_SLUG); - $all_class = $pay_type === '' ? 'is-active' : ''; - echo '
  • ' . esc_html__('全部', 'aksrt') . '
  • '; - 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 '
  • ' . esc_html($label) . '
  • '; + private function render_flash(): void { + $code = isset($_GET['aksrt_pay_msg']) ? sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])) : ''; + if ($code === '') { + return; } - echo '
'; - - NativeUi::card_open(__('筛选', 'aksrt')); - echo '
'; - echo ''; - echo ''; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - echo '

' . esc_html__('本页统计内容付费文章(product_type=resource),不含商城实体商品。订单销量来自已支付/已发货/已完成订单;Meta 销量为缓存计数。', 'aksrt') . '

'; - NativeUi::card_close(); + [$message, $failed] = $map[$code]; + echo '

' . esc_html($message) . '

'; } 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')); } } diff --git a/src/Admin/Native/PayProductsEditor.php b/src/Admin/Native/PayProductsEditor.php new file mode 100644 index 00000000..44b689f8 --- /dev/null +++ b/src/Admin/Native/PayProductsEditor.php @@ -0,0 +1,292 @@ +,由 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 $meta + * @return array + */ + 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 $context 列表筛选与页码,保存后据此回到原位置。 */ + private function render_product_editor(array $context): void { + $currency = $this->currency_symbol(); + echo ''; + echo '
'; + echo ''; + echo ''; + wp_nonce_field(self::SAVE_ACTION, '_wpnonce', false); + foreach ($context as $key => $value) { + echo ''; + } + + echo '
'; + echo '

' . esc_html__('快速设置', 'aksrt') . '

'; + echo '

'; + echo ''; + echo '
'; + + $this->editor_section_open(__('售卖方式', 'aksrt')); + echo '
'; + echo ''; + $this->editor_radios('pay_modo', __('支付方式', 'aksrt'), ['0' => __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')], 'enabled'); + echo '
'; + echo ''; + echo '
'; + $this->editor_radios('pay_limit', __('购买权限', 'aksrt'), array_map('strval', $this->pay_limit_labels())); + echo '
'; + $this->editor_section_close(); + + echo '
'; + $this->editor_section_open(__('价格', 'aksrt'), __('会员价填 0 表示该等级免费,且不能高于售价。', 'aksrt')); + echo '
' . esc_html__('现金价格', 'aksrt') . ''; + $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 ''; + echo '
'; + echo ''; + $this->editor_section_close(); + + $this->editor_section_open(__('销售', 'aksrt')); + echo '
'; + $this->editor_number('pay_count', __('销量浮动', 'aksrt'), '', '1', '', __('在真实销量上增加或减少的数量,可填负数。', 'aksrt'), false); + echo '
'; + $this->editor_checkbox('show_sales', __('前台显示销量', 'aksrt')); + $this->editor_checkbox('coupon_s', __('允许使用优惠码', 'aksrt')); + $this->editor_checkbox('order_expired_s', __('启用订单有效期', 'aksrt')); + echo '
'; + echo ''; + $this->editor_section_close(); + + $this->editor_section_open(__('商品文案', 'aksrt')); + echo '
'; + echo ''; + echo ''; + echo '
'; + $this->editor_section_close(); + echo '
'; + + echo '

' . esc_html__('下载资源、付费图片、视频剧集和隐藏内容请在文章编辑页的「付费功能」面板维护。', 'aksrt') + . ' ' . esc_html__('打开文章编辑页', 'aksrt') . '

'; + + echo '
'; + echo ''; + echo ''; + echo '
'; + } + + private function editor_section_open(string $title, string $hint = ''): void { + echo '

' . esc_html($title) . '

'; + if ($hint !== '') { + echo '

' . esc_html($hint) . '

'; + } + } + + private function editor_section_close(): void { + echo '
'; + } + + /** @param array $options */ + private function editor_radios(string $key, string $title, array $options, string $pane = ''): void { + echo '
'; + echo '' . esc_html($title) . '
'; + foreach ($options as $value => $label) { + echo ''; + } + echo '
'; + } + + private function editor_number(string $key, string $title, string $unit, string $step, string $attributes = '', string $hint = '', bool $non_negative = true): void { + echo ''; + } + + private function editor_checkbox(string $key, string $label): void { + echo ''; + } + + /* ------------------------------------------------------------------ + * 保存 + * ----------------------------------------------------------------*/ + + 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 $meta + * @param array $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 */ + 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 $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; + } +} diff --git a/src/Admin/Native/PayProductsList.php b/src/Admin/Native/PayProductsList.php new file mode 100644 index 00000000..72c1b685 --- /dev/null +++ b/src/Admin/Native/PayProductsList.php @@ -0,0 +1,281 @@ + 当前列表上下文(筛选 + 页码),快速设置保存后据此回到原位置。 + */ + 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 '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + 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 '
' . esc_html__('商品', 'aksrt') . '' . esc_html__('付费类型', 'aksrt') . '' . esc_html__('价格', 'aksrt') . '' . esc_html__('销量', 'aksrt') . '' . esc_html__('销售额', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
'; + 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 各付费类型的文章数(不受搜索词影响,与 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 $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 '
'; + echo '
    '; + 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 '
  • ' + . esc_html($label) . ' ' . esc_html(number_format_i18n($count)) . '
  • '; + } + echo '
'; + + echo '
'; + } + + /** + * @param array $sales + * @param array $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 ''; + + echo ''; + echo $edit_url !== '' + ? '' . esc_html($title) . '' + : esc_html($title); + echo '
#' . 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 '
'; + + echo '' . NativeUi::badge($type_labels[$pay_type] ?? $pay_type, self::TYPE_TONES[$pay_type] ?? 'neutral'); + $limit = (int) $meta['pay_limit']; + if ($limit > 0) { + echo '
' . esc_html($this->pay_limit_labels()[$limit] ?? '') . '
'; + } + echo ''; + + echo '' . $this->price_cell_html($meta, $currency) . ''; + + echo '' . esc_html(sprintf(__('%d 单', 'aksrt'), $orders)) . ''; + echo '
' . esc_html(!empty($meta['show_sales']) + ? sprintf(__('前台显示 %d', 'aksrt'), aksrt_get_content_sales_count($post, $meta)) + : __('前台不显示销量', 'aksrt')) . '
'; + + echo '' . esc_html($this->sales_amount($sales['revenue'] ?? '0.00', $currency)) . ''; + if ($orders > 0) { + echo '
' . esc_html(sprintf(__('均价 %s', 'aksrt'), $this->sales_amount($sales['avg_amount'] ?? '0.00', $currency))) . '
'; + } + echo ''; + + $payload = $this->product_payload($post, $meta, $edit_url); + echo ''; + echo ''; + echo ''; + } + + /** @param array $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 '' . esc_html($main) . '' + . ($original !== '' ? ' ' . esc_html($original) . '' : '') + . '
' . esc_html(sprintf(__('VIP1 %1$s · VIP2 %2$s', 'aksrt'), $vip[0], $vip[1])) . '
'; + } + + /** 订单统计里积分订单为 int、现金订单为两位小数字符串。 */ + private function sales_amount($amount, string $currency): string { + return is_int($amount) + ? sprintf(__('%d 积分', 'aksrt'), $amount) + : $currency . $this->cash_decimal((string) $amount); + } +} diff --git a/src/Admin/Native/PostListEnhance.php b/src/Admin/Native/PostListEnhance.php index de2ec0be..dc39bb72 100644 --- a/src/Admin/Native/PostListEnhance.php +++ b/src/Admin/Native/PostListEnhance.php @@ -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); } /** diff --git a/src/Core/DomainModel.php b/src/Core/DomainModel.php index a7f8430a..8659f020 100644 --- a/src/Core/DomainModel.php +++ b/src/Core/DomainModel.php @@ -226,6 +226,21 @@ class DomainModel { return self::normalize_pay_type($pay_type) === self::PAY_TYPE_DOWNLOAD; } + /** + * 付费 meta 的唯一写入口:清洗后写入主 bag,并同步可查询的派生键(类型 / 价格 / 资源标记)。 + * + * @param array $meta + * @return array 实际写入的清洗结果。 + */ + 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 diff --git a/src/Core/FrontSubmitPay.php b/src/Core/FrontSubmitPay.php index f782ff35..aa07c7ce 100644 --- a/src/Core/FrontSubmitPay.php +++ b/src/Core/FrontSubmitPay.php @@ -205,20 +205,6 @@ trait FrontSubmitPay { * @param array $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); } } diff --git a/src/Core/ResourceMetaBox.php b/src/Core/ResourceMetaBox.php index 6e4fd07c..f7754030 100644 --- a/src/Core/ResourceMetaBox.php +++ b/src/Core/ResourceMetaBox.php @@ -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); } } diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs index b30ac721..130f98a0 100644 --- a/tests/admin/pay-pages-pagination.test.cjs +++ b/tests/admin/pay-pages-pagination.test.cjs @@ -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'), diff --git a/tests/widget-system/post-list-quick-edit-contract.test.cjs b/tests/widget-system/post-list-quick-edit-contract.test.cjs index b33d41f8..c27d48bf 100644 --- a/tests/widget-system/post-list-quick-edit-contract.test.cjs +++ b/tests/widget-system/post-list-quick-edit-contract.test.cjs @@ -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', () => {