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') . '
| ' . esc_html__('商品', 'aksrt') . ' | '; - echo '' . esc_html__('ID', 'aksrt') . ' | '; - echo '' . esc_html__('付费类型', 'aksrt') . ' | '; - echo '' . esc_html__('模式', 'aksrt') . ' | '; - echo '' . esc_html__('标价', 'aksrt') . ' | '; - echo '' . esc_html__('销售均价', 'aksrt') . ' | '; - echo '' . esc_html__('订单销量', 'aksrt') . ' | '; - echo '' . esc_html__('Meta 销量', 'aksrt') . ' | '; - echo '' . esc_html__('销售总额', 'aksrt') . ' | '; - echo '' . esc_html__('操作', 'aksrt') . ' | '; - echo '
|---|---|---|---|---|---|---|---|---|---|
| '; - if ($edit_url) { - echo '' . esc_html($title) . ''; - } else { - echo '' . esc_html($title) . ''; - } - echo ' | '; - echo '' . esc_html((string) $post_id) . ' | '; - echo '' . esc_html((string) $pt_label) . ' | '; - echo '' . esc_html($modo_label) . ' | '; - echo '' . esc_html($price_text) . ' | '; - echo '' . esc_html($avg) . ' | '; - echo '' . esc_html((string) $db_sales) . ' | '; - echo '' . esc_html((string) max(0, $meta_sales)) . ' | '; - echo '' . esc_html($revenue) . ' | '; - echo ''; - if ($edit_url) { - echo '' . esc_html__('编辑', 'aksrt') . ''; - } - echo '' . esc_html__('订单', 'aksrt') . ''; - $permalink = get_permalink($post_id); - if ($permalink) { - echo '' . esc_html__('查看', 'aksrt') . ''; - } - echo ' | '; - echo '
' . esc_html__('本页统计内容付费文章(product_type=resource),不含商城实体商品。订单销量来自已支付/已发货/已完成订单;Meta 销量为缓存计数。', 'aksrt') . '
'; - NativeUi::card_close(); + [$message, $failed] = $map[$code]; + echo '' . esc_html($message) . '
' . esc_html($hint) . '
'; + } + } + + private function editor_section_close(): void { + echo '| ' . esc_html__('商品', 'aksrt') . ' | '; + echo '' . esc_html__('付费类型', 'aksrt') . ' | '; + echo '' . esc_html__('价格', 'aksrt') . ' | '; + echo '' . esc_html__('销量', 'aksrt') . ' | '; + echo '' . esc_html__('销售额', 'aksrt') . ' | '; + echo '' . esc_html__('操作', 'aksrt') . ' | '; + echo '
|---|