From ef718042d2332431ee92765e3f50c4a0c191255a Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 6 Oct 2026 15:54:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=96=87=E7=AB=A0=E5=88=97=E8=A1=A8=E9=94=80=E5=94=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=97=E4=B8=8E=E5=BF=AB=E6=8D=B7=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「所有文章」列表此前只有互动数据列,缺少销售数据表头;快捷/批量编辑 展开后也只有原生三项。本次对照参考主题补齐这两处,不含专题链路。 - 新增 PostListEnhance:统管 sale/stats 两列、付费类型筛选、 orderby=sales_volume 排序,以及四组快捷编辑字段的渲染与保存 - 新增 PostListEditFields:单独承载字段定义,主类职责保持单一 - 新增 admin-post-list.js:按 pay_type/pay_modo 勾选切换字段显隐 - 从 inc/core-hooks.php 迁出原有列实现(删 74 行),不再保留旧闭包 - 清理 admin-main.css 中确认无引用的 .aksrt-tab-pane 与 .aksrt-modal* 死块 - 新增 post-list-quick-edit-contract 测试,更新 stats/icon-picker 契约 快捷编辑锚点列用互动数据列而非参考主题的专题分类法列(本项目无专题)。 真实销量改为页面级批量查询,避免逐行查库。金额运算统一走 Ledger 的 「分」单位,不用裸浮点。保存走 edit_post 钩子,一次覆盖快捷与批量两条路径。 --- assets/css/admin-main.css | 106 +-- assets/js/admin-post-list.js | 72 ++ functions.php | 5 + inc/core-hooks.php | 74 -- src/Admin/Native/PostListEditFields.php | 203 +++++ src/Admin/Native/PostListEnhance.php | 791 ++++++++++++++++++ .../admin-stats-column-contract.test.cjs | 117 ++- .../icon-picker-contract.test.cjs | 9 +- .../post-list-quick-edit-contract.test.cjs | 190 +++++ 9 files changed, 1351 insertions(+), 216 deletions(-) create mode 100644 assets/js/admin-post-list.js create mode 100644 src/Admin/Native/PostListEditFields.php create mode 100644 src/Admin/Native/PostListEnhance.php create mode 100644 tests/widget-system/post-list-quick-edit-contract.test.cjs diff --git a/assets/css/admin-main.css b/assets/css/admin-main.css index fac30a27..eef8286b 100644 --- a/assets/css/admin-main.css +++ b/assets/css/admin-main.css @@ -668,95 +668,6 @@ code { /**---------------------通知结束-------------**/ -/**模态框------*/ -.aksrt-modal-backdrop, -.aksrt-modal { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100050; - opacity: 0; - transition: 0.3s; -} - -.aksrt-modal { - display: none; -} - -.aksrt-modal-backdrop { - background: rgba(0, 0, 0, 0.4); - z-index: 0; -} - -.aksrt-modal.in { - opacity: 1; - overflow-x: hidden; - overflow-y: auto; -} - -.aksrt-modal.in .aksrt-modal-backdrop { - opacity: 1; -} - -.aksrt-modal-dialog { - max-width: calc(100% - 20px); - margin: 0; - transform: scale(0.9); - transition: transform 0.5s cubic-bezier(0.32, 0.85, 0.45, 1.18), width 0.3s; -} - -.aksrt-modal-dialog.modal-mini { - width: 400px; -} - -.aksrt-modal.in .aksrt-modal-dialog { - transform: scale(1); - position: relative; -} - -.aksrt-modal .close.abs-close { - display: none; -} - -.aksrt-modal .border-title { - padding-bottom: 12px; - margin-bottom: 12px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); -} - -.aksrt-modal-content { - max-height: calc(100vh - 20px); - overflow: hidden; - overflow-y: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - border-radius: 6px; - background: #fff; - border: none; -} - -.aksrt-modal-body { - padding: 15px; -} - -.aksrt-modal .loading-mask { - z-index: 1; - background: #fff; - border-radius: 6px; -} - -.aksrt-modal .hide-btn { - position: absolute; - right: 10px; - top: 6px; - font-size: 25px; - opacity: 0.6; - cursor: pointer; - z-index: 9; -} - /**---------批量编辑------------**/ .aksrt-bulk-edit-box { margin: -1px; @@ -774,19 +685,18 @@ code { margin-top: 6px; } -/**---------------TAB-栏目------------**/ -.aksrt-tab-pane { - display: none; - opacity: 0; - transition: 0.15s; +.aksrt-bulk-edit-box select, +.aksrt-bulk-edit-box input[type="text"] { + max-width: 100%; + margin-right: 4px; } -.aksrt-tab-pane.active { - display: block; +.inline-edit-row fieldset .aksrt-bulk-edit-box { + min-width: 168px; } -.aksrt-tab-pane.in { - opacity: 1; +.aksrt-bulk-edit-desc { + color: #787c82; } /**---------------后台设置项目-修改或隐藏-----------**/ diff --git a/assets/js/admin-post-list.js b/assets/js/admin-post-list.js new file mode 100644 index 00000000..7d74d17e --- /dev/null +++ b/assets/js/admin-post-list.js @@ -0,0 +1,72 @@ +/** + * Post list quick-edit dependency toggling. + * + * The pay quick-edit box hides fields whose dependency rules no longer match + * the selected 付费模式 / 支付类型, mirroring the reference theme's field + * visibility. Runs on the WP inline-edit row when it is opened or changed. + */ +(function () { + 'use strict'; + + function readControl(row, fieldId) { + var input = row.querySelector( + '[name="aksrt_bulk_edit[pay][' + fieldId + ']"],' + + ' [name="aksrt_bulk_edit[pay][' + fieldId + '][operation]"]' + ); + if (!input) return null; + if (input.tagName === 'SELECT') return input.value; + var checked = row.querySelector( + '[name="aksrt_bulk_edit[pay][' + fieldId + ']"]:checked' + ); + if (input.type === 'radio' && checked) return checked.value; + return input.value; + } + + function compare(actual, operator, expected) { + if (actual === null) return true; + if (operator === '==') return actual === expected; + if (operator === '!=') return actual !== expected; + return true; + } + + function updateRow(row) { + var boxes = row.querySelectorAll('.aksrt-bulk-edit-box[data-dependency]'); + Array.prototype.forEach.call(boxes, function (box) { + var rules; + try { + rules = JSON.parse(box.getAttribute('data-dependency')); + } catch (e) { + return; + } + if (!Array.isArray(rules)) return; + + var visible = rules.every(function (rule) { + return compare(readControl(row, rule[0]), rule[1], String(rule[2])); + }); + box.style.display = visible ? '' : 'none'; + }); + } + + function syncRows() { + var rows = document.querySelectorAll('.inline-edit-row, #bulk-edit-row'); + Array.prototype.forEach.call(rows, function (row) { + if (row.querySelector('[data-dependency]')) updateRow(row); + }); + } + + document.addEventListener('change', function (event) { + var target = event.target; + if (!target || !target.name || target.name.indexOf('aksrt_bulk_edit[pay]') !== 0) return; + var row = target.closest('.inline-edit-row, #bulk-edit-row'); + if (row) updateRow(row); + }); + + // WP swaps the inline-edit row without a change event. + document.addEventListener('click', function (event) { + var target = event.target; + if (!target || !target.closest) return; + if (target.closest('.editinline, .bulk-action')) window.setTimeout(syncRows, 50); + }); + + syncRows(); +})(); diff --git a/functions.php b/functions.php index 24df1dd4..adc33850 100644 --- a/functions.php +++ b/functions.php @@ -413,6 +413,11 @@ if (is_admin() && class_exists('\AKSRT\Admin\Native\PayIncome')) { \AKSRT\Admin\Native\PayIncome::get_instance()->init(); } +// 后台文章列表增强:互动/销售数据列 + 快捷编辑配置项 +if (is_admin() && class_exists('\AKSRT\Admin\Native\PostListEnhance')) { + \AKSRT\Admin\Native\PostListEnhance::get_instance()->init(); +} + // WordPress 原生用户菜单:消息/推广码/认证/禁封(legacy users.php 子菜单对齐) if (is_admin() && class_exists('\AKSRT\Admin\Native\UsersMenus')) { \AKSRT\Admin\Native\UsersMenus::get_instance()->init(); diff --git a/inc/core-hooks.php b/inc/core-hooks.php index 81eb2982..f57b314b 100644 --- a/inc/core-hooks.php +++ b/inc/core-hooks.php @@ -459,80 +459,6 @@ add_filter('wp_kses_allowed_html', function ($allowed, $context) { return $allowed; }, 10, 2); -// ============================================================ -// WP 后台文章列表增强列 — 浏览/点赞/收藏/下载 -// ============================================================ - -add_filter('manage_post_posts_columns', function ($columns) { - $new_columns = []; - foreach ($columns as $key => $label) { - $new_columns[$key] = $label; - if ($key === 'title') { - $new_columns['aksrt_stats'] = __('互动数据', 'aksrt'); - } - } - return $new_columns; -}); - -add_action('admin_enqueue_scripts', function ($hook) { - if ($hook !== 'edit.php') return; - aksrt_enqueue_fontawesome(); -}); - -add_action('manage_post_posts_custom_column', function ($column, $post_id) { - if ($column !== 'aksrt_stats') return; - - $views = function_exists('aksrt_get_post_views') ? (int) aksrt_get_post_views($post_id) : (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, true); - $likes = function_exists('aksrt_get_post_likes') ? (int) aksrt_get_post_likes($post_id) : (int) get_post_meta($post_id, '_aksrt_like_count', true); - $favorites = function_exists('aksrt_get_post_favorites') ? (int) aksrt_get_post_favorites($post_id) : (int) get_post_meta($post_id, '_aksrt_favorite_count', true); - $downloads = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, true); - - if ($downloads <= 0) { - $legacy_downloads = (int) get_post_meta($post_id, 'download_count', true); - if ($legacy_downloads > 0) { - $downloads = $legacy_downloads; - } - } - - $format = function (int $n): string { - if (function_exists('aksrt_format_number')) { - return (string) aksrt_format_number($n); - } - if ($n >= 10000) return round($n / 10000, 1) . 'W+'; - if ($n >= 1000) return round($n / 1000, 1) . 'K'; - return (string) $n; - }; - - $parts = []; - $parts[] = ' ' . esc_html($format($views)) . ''; - $parts[] = ' ' . esc_html($format($likes)) . ''; - $parts[] = ' ' . esc_html($format($favorites)) . ''; - - if ($downloads > 0) { - $parts[] = ' ' . esc_html($format($downloads)) . ''; - } - - echo implode(' · ', $parts); -}, 10, 2); - -// Support sorting by views/likes/favorites in admin -add_action('pre_get_posts', function ($query) { - if (!is_admin() || !$query->is_main_query()) return; - - $orderby = $query->get('orderby'); - $allowed = [ - 'views' => \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, - 'like' => '_aksrt_like_count', - 'favorite' => '_aksrt_favorite_count', - 'download_count' => \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, - ]; - - if (isset($allowed[$orderby])) { - $query->set('meta_key', $allowed[$orderby]); - $query->set('orderby', 'meta_value_num'); - } -}); - // ============================================================ // Gutenberg 自定义块注册 // ============================================================ diff --git a/src/Admin/Native/PostListEditFields.php b/src/Admin/Native/PostListEditFields.php new file mode 100644 index 00000000..3580dba2 --- /dev/null +++ b/src/Admin/Native/PostListEditFields.php @@ -0,0 +1,203 @@ +> + */ + public static function extended(): array { + return [ + [ + 'id' => 'views', + 'type' => 'number', + 'title' => __('阅读量', 'aksrt'), + ], + [ + 'id' => 'like', + 'type' => 'number', + 'title' => __('点赞数', 'aksrt'), + ], + [ + 'id' => 'show_layout', + 'type' => 'radio', + 'title' => __('显示布局', 'aksrt'), + 'default' => 'false', + 'options' => [ + 'false' => __('跟随主题', 'aksrt'), + 'no_sidebar' => __('无侧边栏', 'aksrt'), + 'sidebar_left' => __('侧边栏靠左', 'aksrt'), + 'sidebar_right' => __('侧边栏靠右', 'aksrt'), + ], + ], + [ + 'id' => 'no_article-navs', + 'type' => 'checkbox', + 'label' => __('不显示目录树', 'aksrt'), + ], + [ + 'id' => 'article_maxheight_xz', + 'type' => 'checkbox', + 'label' => __('限制内容最大高度', 'aksrt'), + ], + ]; + } + + /** + * Pay parameters (saved into the _aksrt_pay meta bag). + * + * @return array> + */ + public static function pay(string $currency): array { + return [ + [ + 'id' => 'pay_type', + 'type' => 'radio', + 'title' => __('付费模式', 'aksrt'), + 'options' => \AKSRT\Core\DomainModel::pay_type_labels(), + ], + [ + 'id' => 'pay_limit', + 'type' => 'radio', + 'title' => __('购买权限', 'aksrt'), + 'options' => [ + '0' => __('所有人可购买', 'aksrt'), + '1' => __('VIP1 及以上会员可购买', 'aksrt'), + '2' => __('仅 VIP2 可购买', 'aksrt'), + ], + ], + [ + 'id' => 'pay_modo', + 'type' => 'radio', + 'title' => __('支付类型', 'aksrt'), + 'options' => [ + '0' => __('普通商品(金钱购买)', 'aksrt'), + 'points' => __('积分商品(积分购买,依赖于用户积分功能)', 'aksrt'), + ], + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '==', 'points']], + 'id' => 'points_price', + 'title' => __('积分售价', 'aksrt'), + 'type' => 'number', + 'unit' => __('积分', 'aksrt'), + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '==', 'points']], + 'title' => __('VIP1 积分售价', 'aksrt'), + 'id' => 'vip1_points', + 'class' => 'compact', + 'subtitle' => __('填 0 则为 VIP1 免费', 'aksrt'), + 'type' => 'number', + 'unit' => __('积分', 'aksrt'), + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '==', 'points']], + 'title' => __('VIP2 积分售价', 'aksrt'), + 'id' => 'vip2_points', + 'class' => 'compact', + 'subtitle' => __('填 0 则为 VIP2 免费', 'aksrt'), + 'desc' => __('会员价格不能高于售价', 'aksrt'), + 'type' => 'number', + 'unit' => __('积分', 'aksrt'), + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '!=', 'points']], + 'id' => 'price', + 'title' => __('执行价', 'aksrt'), + 'type' => 'number', + 'unit' => $currency, + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '!=', 'points']], + 'id' => 'original_price', + 'title' => __('原价', 'aksrt'), + 'class' => 'compact', + 'subtitle' => __('显示在执行价格前面,并划掉', 'aksrt'), + 'type' => 'number', + 'unit' => $currency, + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '!=', 'points']], + 'title' => __('VIP1 价格', 'aksrt'), + 'id' => 'vip1_price', + 'class' => 'compact', + 'subtitle' => __('填 0 则为 VIP1 免费', 'aksrt'), + 'type' => 'number', + 'unit' => $currency, + ], + [ + 'dependency' => [['pay_type', '!=', 'no'], ['pay_modo', '!=', 'points']], + 'title' => __('VIP2 价格', 'aksrt'), + 'id' => 'vip2_price', + 'class' => 'compact', + 'subtitle' => __('填 0 则为 VIP2 免费', 'aksrt'), + 'desc' => __('会员价格不能高于执行价', 'aksrt'), + 'type' => 'number', + 'unit' => $currency, + ], + [ + 'dependency' => [['pay_type', '!=', 'no']], + 'id' => 'pay_count', + 'title' => __('销量浮动', 'aksrt'), + 'subtitle' => __('为真实销量增加或减少的数量', 'aksrt'), + 'type' => 'number', + ], + [ + 'dependency' => [['pay_type', '!=', 'no']], + 'id' => 'show_sales', + 'title' => __('显示销量', 'aksrt'), + 'default' => true, + 'type' => 'switcher', + ], + [ + 'dependency' => [['pay_type', '!=', 'no']], + 'id' => 'coupon_s', + 'title' => __('允许使用优惠码', 'aksrt'), + 'default' => false, + 'type' => 'switcher', + ], + [ + 'dependency' => [['pay_type', '!=', 'no']], + 'id' => 'order_expired_s', + 'title' => __('订单时效', 'aksrt'), + 'default' => false, + 'type' => 'switcher', + ], + [ + 'id' => 'order_expired_time', + 'title' => __('订单时效有效期数值', 'aksrt'), + 'default' => 0, + 'type' => 'number', + ], + [ + 'title' => __('订单时效有效期单位', 'aksrt'), + 'type' => 'select', + 'class' => 'compact', + 'id' => 'order_expired_unit', + 'options' => [ + 'day' => __('天', 'aksrt'), + 'hour' => __('小时', 'aksrt'), + 'minute' => __('分钟', 'aksrt'), + ], + 'default' => 'day', + ], + ]; + } +} diff --git a/src/Admin/Native/PostListEnhance.php b/src/Admin/Native/PostListEnhance.php new file mode 100644 index 00000000..ca27ec36 --- /dev/null +++ b/src/Admin/Native/PostListEnhance.php @@ -0,0 +1,791 @@ +|null */ + private $sales_map = null; + + private function __construct() {} + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + public function init(): void { + add_filter('manage_post_posts_columns', [$this, 'register_columns']); + add_action('manage_post_posts_custom_column', [$this, 'render_column'], 10, 2); + add_filter('manage_edit-post_sortable_columns', [$this, 'register_sortable_columns']); + add_action('restrict_manage_posts', [$this, 'render_type_filter']); + add_action('pre_get_posts', [$this, 'apply_query_args'], 99); + + add_action('quick_edit_custom_box', [$this, 'render_edit_boxes'], 50, 2); + add_action('bulk_edit_custom_box', [$this, 'render_edit_boxes'], 50, 2); + add_action('edit_post', [$this, 'save_edit_boxes'], 10, 2); + + add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); + } + + // ============================================================ + // Assets + // ============================================================ + + public function enqueue_assets(string $hook): void { + if ($hook !== 'edit.php') { + return; + } + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + if (!$screen || $screen->post_type !== 'post' || $screen->base !== 'edit') { + return; + } + + if (function_exists('aksrt_enqueue_fontawesome')) { + aksrt_enqueue_fontawesome(); + } + + $css = AKSRT_DIR . '/assets/css/admin-main.css'; + if (is_file($css)) { + wp_enqueue_style('aksrt-admin-main', AKSRT_URI . '/assets/css/admin-main.css', ['aksrt-font-awesome'], (string) filemtime($css)); + } + $js = AKSRT_DIR . '/assets/js/admin-post-list.js'; + if (is_file($js)) { + wp_enqueue_script('aksrt-post-list', AKSRT_URI . '/assets/js/admin-post-list.js', [], (string) filemtime($js), true); + } + } + + // ============================================================ + // Columns + // ============================================================ + + /** + * Insert the interaction and sales columns after the title column. + * + * @param array $columns + * @return array + */ + public function register_columns($columns) { + if (!is_array($columns)) { + return $columns; + } + + $order = $this->current_order(); + $order_by = $this->current_orderby(); + $o_icon = ''; + + $new_columns = []; + foreach ($columns as $key => $label) { + $new_columns[$key] = $label; + if ($key !== 'title') { + continue; + } + $new_columns[self::COLUMN_STATS] = __('互动数据', 'aksrt'); + $new_columns[self::COLUMN_SALES] = '' . esc_html__('销售数据', 'aksrt') . ($order_by === 'sales_volume' ? $o_icon : '') . ''; + } + + return $new_columns; + } + + /** + * @param array $columns + * @return array + */ + public function register_sortable_columns($columns) { + if (!is_array($columns)) { + return $columns; + } + $columns[self::COLUMN_STATS] = 'views'; + $columns[self::COLUMN_SALES] = 'sales_volume'; + return $columns; + } + + /** + * @param string $column + * @param int $post_id + */ + public function render_column($column, $post_id): void { + if ((string) $column === self::COLUMN_STATS) { + $this->render_stats_column((int) $post_id); + return; + } + if ((string) $column === self::COLUMN_SALES) { + $this->render_sales_column((int) $post_id); + } + } + + private function render_stats_column(int $post_id): void { + $views = function_exists('aksrt_get_post_views') ? (int) aksrt_get_post_views($post_id) : (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, true); + $likes = function_exists('aksrt_get_post_likes') ? (int) aksrt_get_post_likes($post_id) : (int) get_post_meta($post_id, '_aksrt_like_count', true); + $favorites = function_exists('aksrt_get_post_favorites') ? (int) aksrt_get_post_favorites($post_id) : (int) get_post_meta($post_id, '_aksrt_favorite_count', true); + $downloads = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, true); + if ($downloads <= 0) { + $legacy_downloads = (int) get_post_meta($post_id, 'download_count', true); + if ($legacy_downloads > 0) { + $downloads = $legacy_downloads; + } + } + + $format = static function (int $n): string { + if (function_exists('aksrt_format_number')) { + return (string) aksrt_format_number($n); + } + if ($n >= 10000) { + return round($n / 10000, 1) . 'W+'; + } + if ($n >= 1000) { + return round($n / 1000, 1) . 'K'; + } + return (string) $n; + }; + + $parts = []; + $parts[] = ' ' . esc_html($format($views)) . ''; + $parts[] = ' ' . esc_html($format($likes)) . ''; + $parts[] = ' ' . esc_html($format($favorites)) . ''; + + if ($downloads > 0) { + $parts[] = ' ' . esc_html($format($downloads)) . ''; + } + + echo implode(' · ', $parts); + } + + private function render_sales_column(int $post_id): void { + $pay_meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post_id) : []; + $pay_type = (string) ($pay_meta['pay_type'] ?? ''); + if ($pay_type === '' || $pay_type === \AKSRT\Core\DomainModel::PAY_TYPE_NO) { + echo '
' . esc_html__('未开启付费', 'aksrt') . '
'; + return; + } + + $pay_modo = (string) ($pay_meta['pay_modo'] ?? '0'); + $type_labels = \AKSRT\Core\DomainModel::pay_type_labels(); + $type_label = $type_labels[$pay_type] ?? $pay_type; + $type_color = $this->pay_type_color($pay_type); + + if ($pay_modo === 'points') { + $price_text = sprintf(__('积分:%s', 'aksrt'), (string) max(0, (int) ($pay_meta['points_price'] ?? 0))); + } else { + $price_text = $this->currency_symbol() . ':' . (string) ($pay_meta['price'] ?? '0.00'); + } + + $sales_volume = max(0, (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + $real_sales = $this->real_sales_count($post_id); + $real_html = $real_sales > 0 + ? ' [' . esc_html(sprintf(__('真实销量%s', 'aksrt'), $real_sales)) . ']' + : ''; + + $link = add_query_arg(['aksrt_pay_type' => $pay_type]); + echo '
'; + echo '' . esc_html($type_label) . ''; + echo '
' . esc_html($price_text) . ' · ' . esc_html__('销量', 'aksrt') . esc_html((string) $sales_volume) . wp_kses_post($real_html) . '
'; + echo '
'; + } + + /** + * Color palette for the pay-type link, mirroring the reference theme. + */ + private function pay_type_color(string $pay_type): string { + $palette = [ + \AKSRT\Core\DomainModel::PAY_TYPE_READING => '#ff4747', + \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD => '#ee5307', + \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY => '#1e8608', + \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO => '#086ae8', + \AKSRT\Core\DomainModel::PAY_TYPE_VIP => '#3353fd', + ]; + return $palette[$pay_type] ?? '#464646'; + } + + // ============================================================ + // Filter + query + // ============================================================ + + public function render_type_filter(string $post_type): void { + if ($post_type !== 'post') { + return; + } + + $current = isset($_GET['aksrt_pay_type']) ? sanitize_key((string) wp_unslash($_GET['aksrt_pay_type'])) : ''; + $labels = \AKSRT\Core\DomainModel::pay_type_labels(); + unset($labels[\AKSRT\Core\DomainModel::PAY_TYPE_NO]); + + echo ''; + } + + /** + * Map the header orderby links and the product-type filter onto the query. + */ + public function apply_query_args($query): void { + if (!$query instanceof \WP_Query || !is_admin() || !$query->is_main_query()) { + return; + } + if (!isset($_GET['post_type']) || sanitize_key((string) wp_unslash($_GET['post_type'])) !== 'post') { + return; + } + + $orderby = isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : ''; + if ($orderby === 'sales_volume') { + $query->set('meta_key', \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY); + $query->set('orderby', 'meta_value_num'); + } elseif ($orderby === 'views' || $orderby === 'like' || $orderby === 'favorite' || $orderby === 'download_count') { + $query->set('meta_key', $this->stats_meta_key($orderby)); + $query->set('orderby', 'meta_value_num'); + } + + $pay_type = isset($_GET['aksrt_pay_type']) ? sanitize_key((string) wp_unslash($_GET['aksrt_pay_type'])) : ''; + if ($pay_type !== '' && isset(\AKSRT\Core\DomainModel::pay_type_labels()[$pay_type])) { + $meta_query = $query->get('meta_query'); + $meta_query = is_array($meta_query) ? $meta_query : []; + $meta_query[] = [ + 'relation' => 'OR', + [ + 'key' => \AKSRT\Core\DomainModel::PAY_TYPE_KEY, + 'value' => $pay_type, + ], + [ + 'key' => \AKSRT\Core\DomainModel::PAY_META_KEY, + 'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"', + 'compare' => 'LIKE', + ], + ]; + $query->set('meta_query', $meta_query); + } + } + + /** + * Public orderby key → stored meta key for the interaction column. + */ + private function stats_meta_key(string $orderby): string { + $map = [ + 'views' => \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, + 'like' => '_aksrt_like_count', + 'favorite' => '_aksrt_favorite_count', + 'download_count' => \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, + ]; + return $map[$orderby] ?? \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY; + } + + // ============================================================ + // Quick / bulk edit boxes + // ============================================================ + + /** + * @param string $column_name + * @param string $post_type + */ + public function render_edit_boxes($column_name, $post_type): void { + if ($post_type !== 'post' || (string) $column_name !== self::COLUMN_STATS) { + return; + } + + echo wp_kses_post($this->edit_boxes_html()); + } + + private function edit_boxes_html(): string { + $html = $this->fields_html(PostListEditFields::extended(), 'extended'); + $html .= $this->fields_html(PostListEditFields::pay($this->currency_symbol()), 'pay', __('付费参数', 'aksrt'), '
' . esc_html__('批量设置付费参数,请仔细检查配置内容,更新后无法撤回。请注意配置逻辑,例如会员价不能高于普通价,执行价不能高于原价等', 'aksrt') . '
'); + $html .= $this->filter_fields_html(); + return $html; + } + + /** + * Custom-filter dimensions come from settings, so they are rendered + * separately from the static field lists. + */ + private function filter_fields_html(): string { + $dimensions = []; + if (class_exists('\AKSRT\Settings\Settings')) { + $settings = \AKSRT\Settings\Settings::get_post_filter_settings(); + $dimensions = is_array($settings['custom_filter'] ?? null) ? $settings['custom_filter'] : []; + } + if (!$dimensions) { + return ''; + } + + $fields = []; + foreach ($dimensions as $dimension) { + $key = sanitize_key((string) ($dimension['key'] ?? '')); + if ($key === '') { + continue; + } + $options = []; + foreach ((array) ($dimension['vals'] ?? []) as $value) { + $value_key = sanitize_key((string) ($value['key'] ?? '')); + if ($value_key === '') { + continue; + } + $options[$value_key] = (string) ($value['name'] ?? $value_key); + } + if (!$options) { + continue; + } + $fields[] = [ + 'id' => $key, + 'title' => (string) ($dimension['name'] ?? $key), + 'type' => 'checkbox', + 'options' => $options, + 'placeholder' => sprintf(__('请选择%s', 'aksrt'), (string) ($dimension['name'] ?? $key)), + ]; + } + + if (!$fields) { + return ''; + } + + return $this->fields_html($fields, 'filter', __('高级筛选', 'aksrt'), __('让文章实现更加精细化的分类,同时方便用户进行文章筛选', 'aksrt')); + } + + /** + * Render one field group: the shared builder for every quick-edit box. + * + * @param array> $fields + */ + private function fields_html(array $fields, string $id, string $title = '', string $desc = ''): string { + $item_html = ''; + foreach ($fields as $field) { + $options_html = ' '; + switch ($field['type']) { + case 'number': + $name = 'aksrt_bulk_edit[' . $id . '][' . $field['id'] . ']'; + $options_html = '
' + . '' + . '' + . '
'; + break; + + case 'checkbox': + $select_options = !empty($field['options']) ? $field['options'] : []; + if ($select_options) { + $name = 'aksrt_bulk_edit[' . $id . '][' . $field['id'] . ']'; + $options_html = $this->checkbox_options_html($name, $select_options); + break; + } + $field['options'] = [ + 'ignore' => __('不更改', 'aksrt'), + '1' => __('开启', 'aksrt'), + '0' => __('关闭', 'aksrt'), + ]; + // Falls through to the switcher renderer below. + + case 'switcher': + $field['options'] = [ + 'ignore' => __('不更改', 'aksrt'), + '1' => __('开启', 'aksrt'), + '0' => __('关闭', 'aksrt'), + ]; + // Falls through to the select renderer below. + + case 'radio': + case 'select': + $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : []; + $name = 'aksrt_bulk_edit[' . $id . '][' . $field['id'] . ']'; + $options_html = '
' + . '
'; + break; + } + + if (!isset($field['id']) || $options_html === ' ') { + continue; + } + + $title_html = !empty($field['title']) ? (string) $field['title'] : (!empty($field['label']) ? (string) $field['label'] : ''); + $desc_html = !empty($field['desc']) ? '
' . esc_html((string) $field['desc']) . '
' : ''; + $subtitle = !empty($field['subtitle']) ? '
' . esc_html((string) $field['subtitle']) . '
' : ''; + $dependency = !empty($field['dependency']) ? ' data-dependency="' . esc_attr(wp_json_encode($field['dependency'])) . '"' : ''; + + $item_html .= '
' + . '
' . esc_html($title_html) . '
' + . $options_html . $subtitle . $desc_html + . '
'; + } + + if ($item_html === '') { + return ''; + } + + $legend = $title !== '' ? '' . esc_html($title) . '' : ''; + $desc = $desc !== '' ? '
' . esc_html($desc) . '
' : ''; + + return '
' . $legend . '
' . $item_html . '
' . $desc . '
'; + } + + /** + * @param array $options + */ + private function checkbox_options_html(string $name, array $options): string { + $html = ''; + + foreach ($options as $key => $value) { + $html .= '
'; + } + + return '
' . $html . '
'; + } + + // ============================================================ + // Save + // ============================================================ + + /** + * Persist quick/bulk edit submissions for the current post. + * + * @param int $post_id + * @param \WP_Post $post + */ + public function save_edit_boxes(int $post_id, $post): void { + if (!$post instanceof \WP_Post || $post->post_type !== 'post') { + return; + } + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { + return; + } + if (wp_is_post_revision($post_id)) { + return; + } + if (!current_user_can('edit_post', $post_id)) { + return; + } + $screen = isset($_POST['screen']) ? sanitize_key((string) wp_unslash($_POST['screen'])) : ''; + if ($screen !== 'edit-post') { + return; + } + $submitted = isset($_POST['aksrt_bulk_edit']) && is_array($_POST['aksrt_bulk_edit']) ? wp_unslash($_POST['aksrt_bulk_edit']) : []; + if (!$submitted) { + return; + } + + if (!empty($submitted['extended'])) { + $this->save_extended($post_id, (array) $submitted['extended']); + } + if (!empty($submitted['pay'])) { + $this->save_pay($post_id, (array) $submitted['pay']); + } + if (!empty($submitted['filter'])) { + $this->save_filters($post_id, (array) $submitted['filter']); + } + } + + /** + * @param array $fields + */ + private function save_extended(int $post_id, array $fields): void { + foreach ($fields as $field_id => $field_value) { + $field_id = sanitize_key((string) $field_id); + switch ($field_id) { + case 'views': + case 'like': + if (!is_array($field_value) || empty($field_value['operation']) || empty($field_value['val']) || !is_numeric($field_value['val'])) { + break; + } + $operation = sanitize_key((string) $field_value['operation']); + if ($operation === 'ignore') { + break; + } + $value = (float) $field_value['val']; + $old = (float) get_post_meta($post_id, $this->stats_meta_key($field_id), true); + $new = $this->apply_operation($old, $value, $operation); + if ($new !== null) { + update_post_meta($post_id, $this->stats_meta_key($field_id), max(0, (int) $new)); + } + break; + + case 'show_layout': + case 'no_article-navs': + case 'article_maxheight_xz': + $value = is_scalar($field_value) ? (string) $field_value : 'ignore'; + if ($value === 'ignore' || $value === '') { + break; + } + if ($field_id === 'show_layout') { + $value = in_array($value, ['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'], true) ? $value : 'false'; + } else { + $value = in_array($value, ['1', '0'], true) ? $value : '0'; + } + $this->update_post_meta_bag($post_id, $field_id, $value); + break; + } + } + } + + /** + * @param array $fields + */ + private function save_pay(int $post_id, array $fields): void { + $pay_meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post_id) : []; + if (!is_array($pay_meta) || $pay_meta === []) { + $pay_meta = \AKSRT\Core\DomainModel::default_pay_meta(); + } + + foreach ($fields as $field_id => $field_value) { + $field_id = sanitize_key((string) $field_id); + switch ($field_id) { + case 'pay_type': + case 'pay_limit': + case 'pay_modo': + if (is_scalar($field_value) && (string) $field_value !== 'ignore') { + $pay_meta[$field_id] = sanitize_key((string) $field_value); + } + break; + + case 'coupon_s': + case 'order_expired_s': + case 'show_sales': + if (is_scalar($field_value) && in_array((string) $field_value, ['1', '0'], true)) { + $pay_meta[$field_id] = (string) $field_value === '1'; + } + break; + + case 'order_expired_unit': + if (is_scalar($field_value) && in_array((string) $field_value, ['day', 'hour', 'minute'], true)) { + $pay_meta[$field_id] = (string) $field_value; + } + break; + + case 'points_price': + case 'vip1_points': + case 'vip2_points': + case 'order_expired_time': + case 'pay_count': + if (!is_array($field_value) || empty($field_value['operation']) || empty($field_value['val']) || !is_numeric($field_value['val'])) { + break; + } + $operation = sanitize_key((string) $field_value['operation']); + if ($operation === 'ignore') { + break; + } + $current = (float) ($pay_meta[$field_id] ?? 0); + $new = $this->apply_operation($current, (float) $field_value['val'], $operation); + if ($new !== null) { + $pay_meta[$field_id] = max(0, (int) $new); + } + break; + + case 'price': + case 'original_price': + case 'vip1_price': + case 'vip2_price': + if (!is_array($field_value) || empty($field_value['operation']) || empty($field_value['val']) || !is_numeric($field_value['val'])) { + break; + } + $operation = sanitize_key((string) $field_value['operation']); + if ($operation === 'ignore') { + break; + } + $current = (string) ($pay_meta[$field_id] ?? '0.00'); + try { + $current_minor = (float) \AKSRT\Ledger\AssetAmount::balance_minor($current); + } catch (\InvalidArgumentException $e) { + $current_minor = 0.0; + } + $new = $this->apply_operation($current_minor, (float) $field_value['val'], $operation); + if ($new !== null) { + $pay_meta[$field_id] = \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, (int) round($new))); + } + break; + } + } + + $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); + } + + /** + * @param array $fields + */ + private function save_filters(int $post_id, array $fields): void { + $dimensions = []; + if (class_exists('\AKSRT\Settings\Settings')) { + $settings = \AKSRT\Settings\Settings::get_post_filter_settings(); + $dimensions = is_array($settings['custom_filter'] ?? null) ? $settings['custom_filter'] : []; + } + if (!$dimensions) { + return; + } + + $allowed_dimensions = []; + foreach ($dimensions as $dimension) { + $key = sanitize_key((string) ($dimension['key'] ?? '')); + if ($key === '') { + continue; + } + $allowed_dimensions[$key] = array_values(array_filter(array_map(static function ($value): string { + return sanitize_key((string) ($value['key'] ?? '')); + }, (array) ($dimension['vals'] ?? [])))); + } + + foreach ($fields as $field_id => $field_value) { + $field_id = sanitize_key((string) $field_id); + if (!isset($allowed_dimensions[$field_id]) || !is_array($field_value)) { + continue; + } + if (empty($field_value['operation']) || sanitize_key((string) $field_value['operation']) !== 'set') { + continue; + } + $values = isset($field_value['val']) && is_array($field_value['val']) + ? array_map('sanitize_key', $field_value['val']) + : []; + $values = array_values(array_intersect($allowed_dimensions[$field_id], $values)); + if ($values) { + update_post_meta($post_id, '_aksrt_cf_' . $field_id, $values); + } else { + delete_post_meta($post_id, '_aksrt_cf_' . $field_id); + } + } + } + + /** + * Merge one field into the post-meta bag shared with the article extension + * meta box. + */ + private function update_post_meta_bag(int $post_id, string $key, string $value): void { + $bag_key = \AKSRT\Core\PostMetaBox::META_KEY; + $bag = get_post_meta($post_id, $bag_key, true); + $bag = is_array($bag) ? $bag : []; + $bag[$key] = $value; + update_post_meta($post_id, $bag_key, $bag); + } + + /** + * Apply an operation, returning null when it must be ignored. + */ + private function apply_operation(float $current, float $value, string $operation): ?float { + switch ($operation) { + case 'set': + return $value; + case 'plus': + return round($current + $value, 2); + case 'subtract': + return round($current - $value, 2); + case 'multiply': + return round($current * $value, 2); + case 'division': + return ($current != 0 && $value != 0) ? round($current / $value, 2) : null; + } + return null; + } + + // ============================================================ + // Helpers + // ============================================================ + + /** + * Configured cash symbol, shared by the sales column and the pay fields. + */ + private function currency_symbol(): string { + if (class_exists('\AKSRT\Settings\Settings')) { + $paid = \AKSRT\Settings\Settings::get_paid_content_settings(); + return (string) ($paid['display']['currency_symbol'] ?? '¥'); + } + return '¥'; + } + + private function current_order(): string { + $order = isset($_REQUEST['order']) ? sanitize_key((string) wp_unslash($_REQUEST['order'])) : 'desc'; + return $order === 'asc' ? 'asc' : 'desc'; + } + + private function current_orderby(): string { + return isset($_REQUEST['orderby']) ? sanitize_key((string) wp_unslash($_REQUEST['orderby'])) : ''; + } + + /** + * Real paid orders for one post, batched across the current page. + */ + private function real_sales_count(int $post_id): int { + if ($this->sales_map === null) { + $this->sales_map = $this->load_sales_map(); + } + return (int) ($this->sales_map[$post_id] ?? 0); + } + + /** + * @return array + */ + private function load_sales_map(): array { + global $wp_query; + if (!$wp_query instanceof \WP_Query || empty($wp_query->posts)) { + return []; + } + if (!class_exists('\AKSRT\Payment\OrderRepository')) { + return []; + } + + $ids = []; + foreach ($wp_query->posts as $post) { + if ($post instanceof \WP_Post) { + $ids[] = (int) $post->ID; + } + } + if (!$ids) { + return []; + } + + $map = \AKSRT\Payment\OrderRepository::get_instance()->get_content_sales_map( + $ids, + \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE + ); + $out = []; + foreach ($map as $pid => $row) { + $out[(int) $pid] = (int) ($row['sales_count'] ?? 0); + } + return $out; + } + + private function orders_url(int $post_id): string { + return add_query_arg( + [ + 'page' => 'aksrt_orders', + 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, + 'product_id' => $post_id, + ], + admin_url('admin.php') + ); + } +} diff --git a/tests/widget-system/admin-stats-column-contract.test.cjs b/tests/widget-system/admin-stats-column-contract.test.cjs index 48bb838e..36c6388a 100644 --- a/tests/widget-system/admin-stats-column-contract.test.cjs +++ b/tests/widget-system/admin-stats-column-contract.test.cjs @@ -6,61 +6,89 @@ const path = require('node:path') const root = path.resolve(__dirname, '../..') const read = file => fs.readFileSync(path.join(root, file), 'utf8') -const hooks = read('inc/core-hooks.php') +const list = read('src/Admin/Native/PostListEnhance.php') const domain = read('src/Core/DomainModel.php') const tags = read('inc/helpers/aksrt-frontend-tags.php') +const pay = read('inc/helpers/aksrt-pay.php') test('admin post list has the interaction stats column', () => { - assert.match(hooks, /manage_post_posts_columns/) - assert.match(hooks, /互动数据/) - assert.match(hooks, /manage_post_posts_custom_column/) - assert.match(hooks, /\$column !== 'aksrt_stats'/) + assert.match(list, /manage_post_posts_columns/) + assert.match(list, /互动数据/) + assert.match(list, /manage_post_posts_custom_column/) + assert.match(list, /COLUMN_STATS/) +}) + +test('admin post list has the sales data column with a sortable header', () => { + // 表头「销售数据」必须是可点击排序链接,排序键为 sales_volume。 + assert.match(list, /销售数据/) + assert.match(list, /'aksrt_sales'/) + assert.match(list, /orderby.*sales_volume/) + + // 排序列必须在表头注册,并把 sales_volume 映射回真实存储键。 + assert.match(list, /manage_edit-post_sortable_columns/) + assert.match(list, /sales_volume/) + assert.match(list, /RESOURCE_SALES_VOLUME_KEY/) +}) + +test('sales column renders pay type, price and both sales counts', () => { + // 未开启付费 → 提示;已开启 → 类型链接 + 价格 + 销量。 + assert.match(list, /未开启付费/) + assert.match(list, /pay_type_labels\(\)/) + assert.match(list, /真实销量/) + assert.match(list, /get_content_sales_map/) + // 第一行取自 _aksrt_pay,第二行的销量取自 _aksrt_sales_volume。 + assert.match(list, /aksrt_get_pay_meta/) + assert.match(list, /DomainModel::RESOURCE_SALES_VOLUME_KEY/) + // 真实销量链接必须跳到订单页(aksrt_orders)并按商品 ID 过滤。 + assert.match(list, /'aksrt_orders'/) + assert.match(list, /'product_id'/) }) test('interaction counts are read from the keys the theme actually writes', () => { // 计数一律落在这几个 meta key 上:aksrt_post_views / _aksrt_like_count / - // _aksrt_favorite_count / _aksrt_res_download_count。列回调只能用同一套键, - // 否则全列恒为 0。旧实现读 'views' / 'like' / 'favorite',仓库里没有任何写入方。 + // _aksrt_favorite_count / _aksrt_res_download_count。列回调只能用同一套键, + // 否则全列恒为 0。旧实现读 'views' / 'like' / 'favorite',仓库里没有任何写入方。 assert.match(tags, /'_aksrt_like_count'/) assert.match(tags, /'_aksrt_favorite_count'/) assert.match(tags, /'aksrt_post_views'/) - assert.match(hooks, /aksrt_get_post_views\(\$post_id\)/) - assert.match(hooks, /aksrt_get_post_likes\(\$post_id\)/) - assert.match(hooks, /aksrt_get_post_favorites\(\$post_id\)/) + assert.match(list, /aksrt_get_post_views\(\$post_id\)/) + assert.match(list, /aksrt_get_post_likes\(\$post_id\)/) + assert.match(list, /aksrt_get_post_favorites\(\$post_id\)/) // 资源下载量只有 _aksrt_res_download_count 一个写入键。 - assert.match(hooks, /DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) + assert.match(list, /DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) assert.match(domain, /RESOURCE_DOWNLOAD_COUNT_KEY\s*=\s*'_aksrt_res_download_count'/) // 不得再直接读这些不存在的键('download_count' 只作为历史遗留键回退读取)。 - const column = hooks.slice(hooks.indexOf('manage_post_posts_custom_column'), hooks.indexOf('Support sorting by')) + const column = list.slice(list.indexOf('private function render_stats_column'), list.indexOf('private function render_sales_column')) assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'views'/) assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'like'/) assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'favorite'/) }) test('admin stats column shares the aksrt_format_number abbreviation', () => { - // 后台列与前台必须同一套缩写口径(≥10000 → 1.2W+,≥1000 → 1.5K)。 - // 旧实现用了前台从未用过的「万」后缀。 - assert.match(hooks, /aksrt_format_number\(\$n\)/) - const column = hooks.slice(hooks.indexOf('manage_post_posts_custom_column'), hooks.indexOf('Support sorting by')) + // 后台列与前台必须同一套缩写口径(≥10000 → 1.2W+,≥1000 → 1.5K)。 + assert.match(list, /aksrt_format_number\(\$n\)/) + const column = list.slice(list.indexOf('private function render_stats_column'), list.indexOf('private function render_sales_column')) assert.doesNotMatch(column, /'万'/) }) test('sortable meta maps the public orderby keys to the stored keys', () => { - assert.match(hooks, /'views'\s*=>\s*\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/) - assert.match(hooks, /'like'\s*=>\s*'_aksrt_like_count'/) - assert.match(hooks, /'favorite'\s*=>\s*'_aksrt_favorite_count'/) - assert.match(hooks, /'download_count'\s*=>\s*\\AKSRT\\Core\\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) - // meta_key 必须是真实存储键,而不是用户可见的排序键。 - assert.match(hooks, /\$query->set\('meta_key', \$allowed\[\$orderby\]\)/) + assert.match(list, /'views'\s*=>\s*\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/) + assert.match(list, /'like'\s*=>\s*'_aksrt_like_count'/) + assert.match(list, /'favorite'\s*=>\s*'_aksrt_favorite_count'/) + assert.match(list, /'download_count'\s*=>\s*\\AKSRT\\Core\\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) + // meta_key 必须是真实存储键,而不是用户可见的排序键。 + assert.match(list, /\$query->set\('meta_key', \$this->stats_meta_key\(\$orderby\)\)/) }) test('every counter meta key the admin column renders has a writer', () => { - // 逐键核对:列里展示的每项计数都必须在某个 helper 里真正被写入过。 + // 逐键核对:列里展示的每项计数都必须在某个 helper 里真正被写入过。 + // _aksrt_sales_volume 的写入方是付费订单流水(PayOrders),不在这份清单里, + // 由下一个用例单独覆盖。 const sources = [ - 'inc/core-hooks.php', + 'src/Admin/Native/PostListEnhance.php', 'inc/helpers/aksrt-frontend-tags.php', 'src/Core/PostMetaBox.php', 'src/Frontend/PageAdminSet.php', @@ -69,31 +97,38 @@ test('every counter meta key the admin column renders has a writer', () => { for (const key of ['aksrt_post_views', '_aksrt_like_count', '_aksrt_favorite_count']) { assert.ok(sources.includes(`'${key}'`), `${key} is written somewhere`) } - // 资源帖的浏览有独立键,aksrt_get_post_views() 取两者较大值。 + // 资源帖的浏览有独立键,aksrt_get_post_views() 取两者较大值。 assert.match(tags, /_aksrt_res_view_count/) assert.match(read('inc/helpers/aksrt-resource.php'), /RESOURCE_VIEW_COUNT_KEY|_aksrt_res_view_count/) - // 资源下载走普通外链,主题内没有任何写入方,所以下载项只在 >0 时出现。 - // 万一将来接了下载统计,写入方必须落在同一个键上。 - assert.match(hooks, /\$downloads <= 0/) - assert.match(hooks, /\$legacy_downloads/) + // 资源下载走普通外链,主题内没有任何写入方,所以下载项只在 >0 时出现。 + assert.match(list, /\$downloads <= 0/) + assert.match(list, /\$legacy_downloads/) }) -test('download count stays read-only until a writer exists', () => { - // 目前没有任何 PHP 写入 _aksrt_res_download_count;若哪天新增了写入方, - // 这里会失败,提示把该键也纳入后台列的统计口径。 - const php = ['inc', 'src', 'templates'].flatMap(dir => - fs.readdirSync(path.join(root, dir), { withFileTypes: true }) - .filter(e => e.isFile() && e.name.endsWith('.php')) - .map(e => `${dir}/${e.name}`) - ).filter(f => !f.includes('core-hooks') && !f.includes('DomainModel')).map(read).join('\n') - const writers = php.match(/(?:update|add)_post_meta\([^)]*_aksrt_res_download_count/g) || [] - assert.equal(writers.length, 0, 'download count still has no writer') +test('sales volume is written by the order pipeline', () => { + // _aksrt_sales_volume 的写入方在付费助手与订单处理器里,列本身只读。 + assert.match(pay, /aksrt_bump_content_sales_volume/) + assert.match(pay, /RESOURCE_SALES_VOLUME_KEY/) + assert.match(read('src/Content/PayOrders.php'), /aksrt_bump_content_sales_volume/) }) test('admin column icons stay behind the Font Awesome enqueue on edit.php', () => { - assert.match(hooks, /admin_enqueue_scripts[\s\S]*?edit\.php[\s\S]*?aksrt_enqueue_fontawesome\(\)/) + assert.match(list, /admin_enqueue_scripts[\s\S]*?edit\.php[\s\S]*?aksrt_enqueue_fontawesome\(\)/) for (const klass of ['fa-eye', 'fa-thumbs-up', 'fa-star', 'fa-download']) { - assert.match(hooks, new RegExp(klass)) + assert.match(list, new RegExp(klass)) } }) + +test('the old closure-based column implementation is gone from core-hooks', () => { + // 列与排序逻辑统一收在 src/Admin/Native/PostListEnhance.php,inc/core-hooks.php + // 不再重复注册,否则同一列会被渲染两次。 + const hooks = read('inc/core-hooks.php') + assert.doesNotMatch(hooks, /manage_post_posts_columns/) + assert.doesNotMatch(hooks, /manage_post_posts_custom_column/) + assert.doesNotMatch(hooks, /aksrt_stats/) +}) + +test('PostListEnhance is wired up from functions.php', () => { + assert.match(read('functions.php'), /PostListEnhance::get_instance\(\)->init\(\)/) +}) diff --git a/tests/widget-system/icon-picker-contract.test.cjs b/tests/widget-system/icon-picker-contract.test.cjs index 3ea06210..011eebe0 100644 --- a/tests/widget-system/icon-picker-contract.test.cjs +++ b/tests/widget-system/icon-picker-contract.test.cjs @@ -78,16 +78,19 @@ test('icon values sanitize through the SVG-aware helper on every save path', () test('FA5 loads under a single canonical handle everywhere', () => { const picker = read('inc/helpers/aksrt-icon-picker.php'); const admin = read('src/Admin/Admin.php'); - const coreHooks = read('inc/core-hooks.php'); + // 文章列表(edit.php)的 FA 入队随列增强迁到了 PostListEnhance。 + const postList = read('src/Admin/Native/PostListEnhance.php'); const part1b = read('src/Core/ScriptsPart1b.php'); assert.match(picker, /aksrt_enqueue_fontawesome\(\)/); assert.match(admin, /aksrt_enqueue_fontawesome\(\)/); - assert.match(coreHooks, /aksrt_enqueue_fontawesome\(\)/); + assert.match(postList, /aksrt_enqueue_fontawesome\(\)/); assert.match(part1b, /aksrt_enqueue_fontawesome\(\['bootstrap'\]\)/); assert.match(part1b, /\['aksrt-font-awesome'\]/, 'public main css depends on the canonical handle'); - for (const file of [picker, admin, coreHooks, part1b]) { + for (const file of [picker, admin, postList, part1b]) { assert.doesNotMatch(file, /wp_enqueue_style\(\s*'aksrt-font-awesome'/, 'no duplicate FA registrations'); } + // core-hooks.php 不该再自己注册或重复入队 FA。 + assert.doesNotMatch(read('inc/core-hooks.php'), /aksrt_enqueue_fontawesome|wp_enqueue_style\(\s*'aksrt-font-awesome'/); for (const file of ['inc/helpers/aksrt-widgets-markup.php', 'src/WidgetSystem/Renderers/ContentRenderer.php']) { const source = read(file); assert.doesNotMatch(source, /fa-clock-o|fa-folder-open-o|fa-file-text-o/, `${file} uses FA5 names`); diff --git a/tests/widget-system/post-list-quick-edit-contract.test.cjs b/tests/widget-system/post-list-quick-edit-contract.test.cjs new file mode 100644 index 00000000..ac9fcd98 --- /dev/null +++ b/tests/widget-system/post-list-quick-edit-contract.test.cjs @@ -0,0 +1,190 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '../..') +const read = file => fs.readFileSync(path.join(root, file), 'utf8') + +const list = read('src/Admin/Native/PostListEnhance.php') +const fields = read('src/Admin/Native/PostListEditFields.php') +const domain = read('src/Core/DomainModel.php') +const pay = read('inc/helpers/aksrt-pay.php') +const metabox = read('src/Core/PostMetaBox.php') +const resourceBox = read('src/Core/ResourceMetaBox.php') + +test('quick and bulk edit boxes hook the post list with no topics column', () => { + // 参考主题把配置项挂在「专题」分类法列上;本项目没有专题,统一挂在互动数据列。 + assert.match(list, /quick_edit_custom_box/) + assert.match(list, /bulk_edit_custom_box/) + assert.match(list, /COLUMN_STATS/) + // 代码里不得再出现专题分类法列(类文档块的说明除外,只看 namespace 之后的部分)。 + const code = list.slice(list.indexOf('namespace AKSRT')) + assert.doesNotMatch(code, /taxonomy-topics/) + assert.doesNotMatch(code, /topics/) +}) + +test('every quick-edit field group is rendered', () => { + // 四组字段的入口函数与分组标识都要存在,缺一组该列就少一块配置。 + assert.match(list, /render_edit_boxes/) + assert.match(list, /PostListEditFields::extended\(\)/) + assert.match(list, /PostListEditFields::pay\(\$this->currency_symbol\(\)\)/) + assert.match(list, /filter_fields_html\(\)/) + assert.match(list, /'pay'/) + assert.match(list, /'filter'/) + // 字段定义本体放在独立的 PostListEditFields,两个静态方法必须真的存在。 + assert.match(fields, /function extended\(\): array/) + assert.match(fields, /function pay\(string \$currency\): array/) +}) + +test('quick-edit inputs are namespaced under aksrt_bulk_edit', () => { + // 字段名统一 aksrt_bulk_edit[{id}][{field}],避免与 WP 原生数据冲突。 + assert.match(list, /aksrt_bulk_edit\[' \. \$id \. '\]\[' \. \$field\['id'\] \. '\]/) + // number 类型带运算子字段。 + assert.match(list, /\[operation\]/) + assert.match(list, /\[val\]/) +}) +test('number fields support the full operation set', () => { + for (const op of ['ignore', 'set', 'plus', 'subtract', 'multiply', 'division']) { + assert.match(list, new RegExp(`value="${op}"`), `${op} operation exists`) + } +}) + +test('all control types used by the fields exist in the renderer', () => { + for (const type of ['number', 'checkbox', 'switcher', 'radio', 'select']) { + assert.match(list, new RegExp(`case '${type}':`), `${type} renderer exists`) + } +}) + +test('interaction quick-edit fields write to the keys the theme reads', () => { + // 阅读量/点赞数是唯一可批量设置的互动项,键必须与前端 helper 一致。 + assert.match(fields, /'views'/) + assert.match(fields, /'like'/) + assert.match(metabox, /DomainModel::POST_VIEW_COUNT_KEY, max\(0, \(int\) \(\$raw\['views'\]/) + assert.match(metabox, /'_aksrt_like_count', max\(0, \(int\) \(\$raw\['like'\]/) + // 保存侧按这两个键分流到统计 meta。 + assert.match(list, /stats_meta_key/) +}) + +test('article config quick-edit fields match the extension meta box keys', () => { + // 显示布局 + 两个开关必须与「文章扩展」metabox 的键名一致。 + assert.match(metabox, /show_layout/) + assert.match(metabox, /no_article-navs/) + assert.match(metabox, /article_maxheight_xz/) + + // 布局取值白名单:metabox 的 sanitize 是裸数组,快捷编辑提供同四值的单选。 + assert.match(metabox, /\['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'\]/) + assert.match(fields, /'false'\s+=> __\('跟随主题', 'aksrt'\)/) + assert.match(fields, /'no_sidebar'\s+=> __\('无侧边栏', 'aksrt'\)/) + assert.match(fields, /'sidebar_left'\s+=> __\('侧边栏靠左', 'aksrt'\)/) + assert.match(fields, /'sidebar_right'\s+=> __\('侧边栏靠右', 'aksrt'\)/) + // 保存侧再用同一份白名单收敛,避免快捷编辑写入未知布局。 + assert.match(list, /\['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'\]/) + + // 落到 aksrt_post_meta 这个共享 bag 上,与 metabox 同一键(self::META_KEY)。 + assert.match(metabox, /post_meta/) + assert.match(list, /PostMetaBox::META_KEY/) +}) + +test('pay quick-edit fields map onto the real pay meta schema', () => { + // 逐字段核对:快捷编辑写入的键必须存在于默认付费 meta / metabox 中。 + const payKeys = [ + 'pay_type', 'pay_modo', 'pay_limit', + 'points_price', 'vip1_points', 'vip2_points', + 'price', 'original_price', 'vip1_price', 'vip2_price', + 'pay_count', 'coupon_s', 'order_expired_s', + 'order_expired_time', 'order_expired_unit', 'show_sales', + ] + for (const key of payKeys) { + assert.match(domain, new RegExp(`'${key}'\\s*=>`), `pay meta has ${key}`) + assert.match(fields, new RegExp(`'${key}'`), `quick edit offers ${key}`) + } +}) + +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/) +}) + +test('pay prices go through the ledger, not raw floats', () => { + // 金额字段运算在「分」上进行,避免浮点误差。 + assert.match(list, /balance_minor|price_to_minor/) + assert.match(list, /decimal_from_minor/) + assert.match(list, /\$pay_meta\[\$field_id\] = \\AKSRT\\Ledger\\AssetAmount::decimal_from_minor/) +}) + +test('quick-edit save ignores invalid operations and clamps negatives', () => { + assert.match(list, /apply_operation/) + assert.match(list, /case 'set'/) + assert.match(list, /case 'plus'/) + assert.match(list, /case 'subtract'/) + assert.match(list, /case 'multiply'/) + assert.match(list, /case 'division'/) + // 除法要防零。 + assert.match(list, /\$current != 0 && \$value != 0/) + // 负值归零。 + assert.match(list, /max\(0, \(int\) \$new\)/) +}) + +test('custom filter quick-edit fields reuse the settings dimensions', () => { + // 高级筛选项从同一份设置里动态生成,键名前缀与 metabox 保存一致。 + assert.match(list, /get_post_filter_settings/) + assert.match(list, /custom_filter/) + assert.match(list, /'_aksrt_cf_' \. \$field_id/) + assert.match(metabox, /'_aksrt_cf_' \. \$key/) +}) + +test('quick-edit save is guarded by screen, autosave, revision and capability', () => { + assert.match(list, /DOING_AUTOSAVE/) + assert.match(list, /wp_is_post_revision/) + assert.match(list, /current_user_can\('edit_post', \$post_id\)/) + assert.match(list, /edit-post/) + // 只处理 post 类型,不动页面、CPT 与其他列表。 + assert.match(list, /\$post->post_type !== 'post'/) +}) + +test('no legacy zib-style field names or topic links survive', () => { + assert.doesNotMatch(list, /zib_bulk_edit/) + assert.doesNotMatch(list, /posts_zibpay/) + assert.doesNotMatch(list, /zibpay_type/) + assert.doesNotMatch(list, /pay_cuont/) + // 本项目不使用「自动售卡」类目。 + assert.doesNotMatch(list, /售卡/) +}) + +test('the old inline column code was fully migrated', () => { + // inc/core-hooks.php 不再有任何文章列表列/排序逻辑,全部由新类负责。 + // (L295 的 pre_get_posts 属于前台查询,不是后台排序列增强;这里只锁列表增强的部分。) + const hooks = read('inc/core-hooks.php') + assert.doesNotMatch(hooks, /manage_post_posts_columns/) + assert.doesNotMatch(hooks, /manage_post_posts_custom_column/) + assert.doesNotMatch(hooks, /manage_edit-post_sortable_columns/) + assert.doesNotMatch(hooks, /aksrt_enqueue_fontawesome/) + assert.doesNotMatch(hooks, /aksrt_stats/) +}) + +test('the dead tab-pane and modal css blocks are removed', () => { + // 这些选择器无任何引用,已确认删除;文件仍保留 quick-edit 需要的轮子类。 + const css = read('assets/css/admin-main.css') + assert.doesNotMatch(css, /aksrt-tab-pane/) + assert.doesNotMatch(css, /\.aksrt-modal\b/) + assert.doesNotMatch(css, /\.aksrt-modal-backdrop\b/) + // 快捷编辑卡片与依赖样式仍在。 + assert.match(css, /\.aksrt-bulk-edit-box/) + assert.match(css, /\.aksrt-bulk-edit-desc/) + assert.match(css, /\.flex\s*\{/) +}) + +test('the quick-edit dependency script exists and toggles by data-dependency', () => { + assert.ok(fs.existsSync(path.join(root, 'assets/js/admin-post-list.js'))) + const js = read('assets/js/admin-post-list.js') + assert.match(js, /data-dependency/) + assert.match(js, /aksrt_bulk_edit\[pay\]/) + assert.match(js, /inline-edit-row/) +})