feat(admin): 新增后台文章列表销售数据列与快捷编辑配置项

「所有文章」列表此前只有互动数据列,缺少销售数据表头;快捷/批量编辑
展开后也只有原生三项。本次对照参考主题补齐这两处,不含专题链路。

- 新增 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 钩子,一次覆盖快捷与批量两条路径。
This commit is contained in:
2026-10-06 15:54:47 +08:00
parent 194280af72
commit ef718042d2
9 changed files with 1351 additions and 216 deletions
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* Quick-edit field definitions for the admin post list.
*
* Column registration, rendering and saving live in PostListEnhance; this file
* only describes the field groups so the two concerns stay separately readable.
* Every key here must exist in DomainModel::default_pay_meta(),
* DomainModel::default_post_meta() or the custom-filter settings — the
* post-list-quick-edit contract test enforces that pairing.
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
class PostListEditFields {
/**
* Interaction counts + article display config (saved to post meta).
*
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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',
],
];
}
}
+791
View File
@@ -0,0 +1,791 @@
<?php
/**
* Admin post list enhancements: interaction + sales columns and the
* quick/bulk edit fields behind them.
*
* Mirrors the reference theme's post list: a sortable “销售数据” column, a
* product-type filter, and four quick-edit field groups (interaction data,
* article config, pay parameters, custom filters). The reference theme hooks
* those boxes onto its topics taxonomy column; this project has no topics
* taxonomy, so they hang off the interaction-data column instead.
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
class PostListEnhance {
public const COLUMN_STATS = 'aksrt_stats';
public const COLUMN_SALES = 'aksrt_sales';
private static $instance = null;
/** Real sales counts for the current page, keyed by post ID. @var array<int,int>|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<string,string> $columns
* @return array<string,string>
*/
public function register_columns($columns) {
if (!is_array($columns)) {
return $columns;
}
$order = $this->current_order();
$order_by = $this->current_orderby();
$o_icon = '<i class="opacity5 ml3 fa fa-long-arrow-' . ($order === 'asc' ? 'down' : 'up') . '"></i>';
$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] = '<a href="' . esc_url(add_query_arg(['orderby' => 'sales_volume', 'order' => $order])) . '"><span>' . esc_html__('销售数据', 'aksrt') . ($order_by === 'sales_volume' ? $o_icon : '') . '</span></a>';
}
return $new_columns;
}
/**
* @param array<string,string> $columns
* @return array<string,string>
*/
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[] = '<span title="' . esc_attr__('浏览', 'aksrt') . '" style="margin-right:4px;"><i class="fa fas fa-eye" style="color:#8a909c;font-size:12px;"></i> ' . esc_html($format($views)) . '</span>';
$parts[] = '<span title="' . esc_attr__('点赞', 'aksrt') . '" style="margin-right:4px;"><i class="fa fas fa-thumbs-up" style="color:#1890ff;font-size:12px;"></i> ' . esc_html($format($likes)) . '</span>';
$parts[] = '<span title="' . esc_attr__('收藏', 'aksrt') . '" style="margin-right:4px;"><i class="fa fas fa-star" style="color:#f39c12;font-size:12px;"></i> ' . esc_html($format($favorites)) . '</span>';
if ($downloads > 0) {
$parts[] = '<span title="' . esc_attr__('下载', 'aksrt') . '"><i class="fa fas fa-download" style="color:#52c41a;font-size:12px;"></i> ' . esc_html($format($downloads)) . '</span>';
}
echo implode(' &middot; ', $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 '<div style="font-size: 12px;">' . esc_html__('未开启付费', 'aksrt') . '</div>';
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
? ' <a title="' . esc_attr__('点击查看销售明细', 'aksrt') . '" href="' . esc_url($this->orders_url($post_id)) . '">[' . esc_html(sprintf(__('真实销量%s', 'aksrt'), $real_sales)) . ']</a>'
: '';
$link = add_query_arg(['aksrt_pay_type' => $pay_type]);
echo '<div>';
echo '<a style="color: ' . esc_attr($type_color) . ';" href="' . esc_url($link) . '">' . esc_html($type_label) . '</a>';
echo '<div style="font-size: 12px;">' . esc_html($price_text) . ' · ' . esc_html__('销量', 'aksrt') . esc_html((string) $sales_volume) . wp_kses_post($real_html) . '</div>';
echo '</div>';
}
/**
* 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 '<select name="aksrt_pay_type">';
echo '<option value="">' . esc_html__('商品类型', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
printf('<option %s value="%s">%s</option>', selected($current, $key, false), esc_attr($key), esc_html($label));
}
echo '</select>';
}
/**
* 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'), '<div class="c-yellow">' . esc_html__('批量设置付费参数,请仔细检查配置内容,更新后无法撤回。请注意配置逻辑,例如会员价不能高于普通价,执行价不能高于原价等', 'aksrt') . '</div>');
$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<int,array<string,mixed>> $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 = '<div class="flex ac hh">'
. '<select name="' . esc_attr($name) . '[operation]">'
. '<option value="ignore" selected="selected">' . esc_html__('不更改', 'aksrt') . '</option>'
. '<option value="set">' . esc_html__('设置为', 'aksrt') . '</option>'
. '<option value="plus">' . esc_html__('加', 'aksrt') . '</option>'
. '<option value="subtract">' . esc_html__('减', 'aksrt') . '</option>'
. '<option value="multiply">' . esc_html__('乘', 'aksrt') . '</option>'
. '<option value="division">' . esc_html__('除', 'aksrt') . '</option>'
. '</select>'
. '<input type="text" name="' . esc_attr($name) . '[val]" value="">'
. '</div>';
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 = '<div class="flex ac hh">'
. '<select name="' . esc_attr($name) . '">'
. '<option selected="selected" value="ignore">--' . esc_html__('不更改', 'aksrt') . '--</option>';
foreach ($options as $key => $value) {
$options_html .= '<option value="' . esc_attr((string) $key) . '">' . esc_html((string) $value) . '</option>';
}
$options_html .= '</select></div>';
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']) ? '<div class="px12 aksrt-bulk-edit-desc">' . esc_html((string) $field['desc']) . '</div>' : '';
$subtitle = !empty($field['subtitle']) ? '<div class="px12 aksrt-bulk-edit-desc">' . esc_html((string) $field['subtitle']) . '</div>' : '';
$dependency = !empty($field['dependency']) ? ' data-dependency="' . esc_attr(wp_json_encode($field['dependency'])) . '"' : '';
$item_html .= '<div class="aksrt-bulk-edit-box"' . $dependency . '>'
. '<div>' . esc_html($title_html) . '</div>'
. $options_html . $subtitle . $desc_html
. '</div>';
}
if ($item_html === '') {
return '';
}
$legend = $title !== '' ? '<legend class="inline-edit-legend">' . esc_html($title) . '</legend>' : '';
$desc = $desc !== '' ? '<div class="px12 aksrt-bulk-edit-desc">' . esc_html($desc) . '</div>' : '';
return '<fieldset style="margin-top: 20px">' . $legend . '<div class="flex hh">' . $item_html . '</div>' . $desc . '</fieldset>';
}
/**
* @param array<string,string> $options
*/
private function checkbox_options_html(string $name, array $options): string {
$html = '<select class="mr10" name="' . esc_attr($name) . '[operation]">'
. '<option value="ignore" selected="selected">' . esc_html__('不更改', 'aksrt') . '</option>'
. '<option value="set">' . esc_html__('设置为', 'aksrt') . '</option>'
. '</select>';
foreach ($options as $key => $value) {
$html .= '<div class="mr10"><label><input value="' . esc_attr((string) $key) . '" type="checkbox" name="' . esc_attr($name) . '[val][]"> ' . esc_html((string) $value) . '</label></div>';
}
return '<div class="flex ac hh">' . $html . '</div>';
}
// ============================================================
// 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<string,mixed> $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<string,mixed> $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<string,mixed> $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<int,int>
*/
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')
);
}
}