' . 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__('批量设置付费参数,请仔细检查配置内容,更新后无法撤回。请注意配置逻辑,例如会员价不能高于普通价,执行价不能高于原价等', '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 !== '' ? '' : '';
+ $desc = $desc !== '' ? '' . esc_html($desc) . '
' : '';
+
+ return '';
+ }
+
+ /**
+ * @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/)
+})