feat(admin): 添加产品包有效列表动态计算与展示功能

实现产品包配置界面中有效产品列表的动态计算与实时展示功能,包括:
1. 根据选择模式(include/exclude/all)动态计算有效产品
2. 添加AJAX端点计算有效产品列表
3. 在前端展示有效产品数量与列表
4. 优化产品查询包含所有目录可见性的产品
5. 添加模式切换时的界面状态更新
This commit is contained in:
tikkhun 2025-11-07 17:54:57 +08:00
parent bb12f2cd33
commit dac5f1ab84
4 changed files with 261 additions and 40 deletions

View File

@ -8,6 +8,8 @@
var $addAllBtn = $('.yoone-add-all-simple-products'); var $addAllBtn = $('.yoone-add-all-simple-products');
var $clearBtn = $('.yoone-clear-products-list'); var $clearBtn = $('.yoone-clear-products-list');
var $productSelect = $('select[name="yoone_bundle_allowed_products[]"]'); var $productSelect = $('select[name="yoone_bundle_allowed_products[]"]');
var $selectMode = $('#yoone_bundle_select_mode');
var $productsField = $('#yoone_bundle_products_list_field');
if (typeof YoonePBAdmin === 'undefined') { if (typeof YoonePBAdmin === 'undefined') {
return; return;
@ -41,10 +43,44 @@
refreshTerms(val); refreshTerms(val);
}); });
function updateProductsListState() {
var mode = $selectMode.val();
var isAll = (mode === 'all');
// 在 All 模式下,隐藏整个选择框字段;其它模式显示并更新标签文案
if ($productsField.length) {
$productsField.toggle(!isAll);
var $label = $productsField.find('> label');
if ($label.length) {
$label.text(mode === 'exclude' ? 'Excluded Products' : 'Included Products');
}
var $desc = $productsField.find('.yoone-bundle-list-desc');
if ($desc.length) {
if (mode === 'exclude') {
$desc.text('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.');
} else {
$desc.text('Simple products only. In Include mode, ONLY the listed products will be available.');
}
}
}
// 额外:在 All 模式下禁用按钮与选择框(避免误操作)
$productSelect.prop('disabled', isAll);
$addAllBtn.prop('disabled', isAll);
}
if ($selectMode.length) {
updateProductsListState();
$selectMode.on('change', updateProductsListState);
}
// Add all simple products to the products select // Add all simple products to the products select
if ($addAllBtn.length && $productSelect.length) { if ($addAllBtn.length && $productSelect.length) {
$addAllBtn.on('click', function(){ $addAllBtn.on('click', function(){
var $btn = $(this); var $btn = $(this);
var mode = $selectMode.val();
if (mode === 'exclude') {
// 在排除模式下“添加全部”会导致有效商品为空,先弹出确认。
var ok = window.confirm('You are in Exclude mode. Adding ALL simple products to the exclusion list will result in NO effective products. Continue?');
if (!ok) return;
}
$btn.prop('disabled', true); $btn.prop('disabled', true);
$productSelect.prop('disabled', true); $productSelect.prop('disabled', true);
$.post(YoonePBAdmin.ajax_url, { $.post(YoonePBAdmin.ajax_url, {
@ -83,3 +119,59 @@
} }
}); });
})(jQuery); })(jQuery);
function renderEffectiveList(items, count) {
var $list = $('#yoone_effective_products_list');
var $count = $('.yoone-effective-count');
if (!$list.length || !$count.length) return;
$count.text(count || 0);
var html = '';
if (items && items.length) {
html += '<ul class="yoone-effective-list" style="max-height:220px; overflow:auto; margin:0; padding-left:1.5em; border:1px solid #ddd; background:#fff;">';
items.forEach(function(item){
html += '<li>' + $('<div/>').text(item.text).html() + ' <span style="color:#999;">(#' + item.id + ')</span></li>';
});
html += '</ul>';
} else {
html += '<p class="description">No products will be available with the current configuration.</p>';
}
$list.html(html);
}
function recomputeEffective() {
if (typeof YoonePBAdmin === 'undefined') return;
var mode = $selectMode.val();
var allowed = $productSelect.val() || [];
$.post(YoonePBAdmin.ajax_url, {
action: 'yoone_calc_effective_products',
security: YoonePBAdmin.security,
select_mode: mode,
allowed_products: allowed
}).done(function(resp){
if (resp && resp.success && resp.data) {
renderEffectiveList(resp.data.items || [], resp.data.count || 0);
}
});
}
// 初始化:根据当前模式更新显示并首次计算有效列表
updateProductsListState();
recomputeEffective();
// 事件:模式切换与列表变更时重新计算
if ($selectMode.length) {
$selectMode.on('change', function(){
updateProductsListState();
recomputeEffective();
});
}
if ($productSelect.length) {
$productSelect.on('change', recomputeEffective);
}
// 刷新按钮:手动触发重新计算
var $refresh = $('.yoone-refresh-effective-products');
if ($refresh.length) {
$refresh.on('click', function(){
recomputeEffective();
});
}

View File

@ -24,6 +24,8 @@ class Yoone_Product_Bundles_Admin {
// AJAX: 获取所有 simple product // AJAX: 获取所有 simple product
add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products')); add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products'));
// AJAX: 动态计算有效产品列表(根据选择模式与当前列表,不依赖已保存的配置)
add_action('wp_ajax_yoone_calc_effective_products', array($this, 'ajax_calc_effective_products'));
// AJAX: 根据所选 taxonomy 获取术语列表 // AJAX: 根据所选 taxonomy 获取术语列表
add_action('wp_ajax_yoone_get_taxonomy_terms', array($this, 'ajax_get_taxonomy_terms')); add_action('wp_ajax_yoone_get_taxonomy_terms', array($this, 'ajax_get_taxonomy_terms'));
@ -44,8 +46,23 @@ class Yoone_Product_Bundles_Admin {
public function render_product_data_panel() { public function render_product_data_panel() {
global $post; global $post;
$product = wc_get_product($post->ID); $product = wc_get_product($post->ID);
// 前端使用的“有效产品集合”等从 get_bundle_config 计算;但后台的选择框应显示“原始配置列表”(在 include 模式为包含列表,在 exclude 模式为排除列表,在 all 模式忽略该列表)。
$config = Yoone_Product_Bundles::get_bundle_config($product); $config = Yoone_Product_Bundles::get_bundle_config($product);
$allowed = $config['allowed_products']; // 读取原始配置列表(不应用 include/exclude/all 的推导),并仅保留 simple 产品ID。
$allowed_raw = get_post_meta($post->ID, Yoone_Product_Bundles::META_ALLOWED_PRODUCTS, true);
$allowed_raw = is_array($allowed_raw) ? array_values(array_map('absint', $allowed_raw)) : array();
if (! empty($allowed_raw)) {
$simple_only = array();
foreach ($allowed_raw as $aid) {
$p = wc_get_product($aid);
if ($p && $p->is_type('simple')) {
$simple_only[] = $aid;
}
}
$allowed_raw = $simple_only;
}
// 供渲染“有效产品总数”的计算结果
$effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array();
$min_qty = $config['min_qty']; $min_qty = $config['min_qty'];
$select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include'; $select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat'; $group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
@ -73,14 +90,19 @@ class Yoone_Product_Bundles_Admin {
)); ));
// Allowed/Excluded products: use Woo's product search (select2), multiple // Allowed/Excluded products: use Woo's product search (select2), multiple
echo '<p class="form-field"><label>' . esc_html__('Products List', 'yoone-product-bundles') . '</label>'; if ($select_mode !== 'all') {
$list_label = ($select_mode === 'exclude')
? esc_html__('Excluded Products', 'yoone-product-bundles')
: esc_html__('Included Products', 'yoone-product-bundles');
echo '<p class="form-field" id="yoone_bundle_products_list_field"><label>' . $list_label . '</label>';
echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>'; echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
echo ' '; echo ' ';
echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>'; echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>';
// Search only for products, not variations, to avoid errors // Search only for products, not variations, to avoid errors
echo '<select class="wc-product-search" multiple style="width: 100%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">'; echo '<select class="wc-product-search" multiple style="width: 100%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
if (! empty($allowed)) { // 显示“原始配置列表”作为可编辑项exclude 模式记为排除列表include 模式记为包含列表)
foreach ($allowed as $pid) { if (! empty($allowed_raw)) {
foreach ($allowed_raw as $pid) {
$p = wc_get_product($pid); $p = wc_get_product($pid);
if ($p) { if ($p) {
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name())); printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
@ -89,8 +111,38 @@ class Yoone_Product_Bundles_Admin {
} }
echo '</select>'; echo '</select>';
// Dynamic description based on selection mode // Dynamic description based on selection mode
echo '<span class="description">' . esc_html__('Simple products only. Include: only listed; Exclude: allow all except listed; All: ignore the list.', 'yoone-product-bundles') . '</span>'; if ($select_mode === 'include') {
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . '</span>';
} else { // exclude
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . '</span>';
}
echo '</p>'; echo '</p>';
} else {
// All 模式:不显示选择框,仅提示说明
echo '<p class="form-field" id="yoone_bundle_products_list_field" style="display:none"></p>';
}
// 只读展示:有效产品列表与总数(基于当前配置推导)
echo '<div id="yoone_effective_products_block" class="yoone-effective-products" style="margin-top:10px;">';
echo '<div class="yoone-effective-actions" style="margin-bottom:6px;">';
echo '<button type="button" class="button yoone-refresh-effective-products">' . esc_html__('Refresh Effective Products', 'yoone-product-bundles') . '</button>';
echo '</div>';
echo '<p>' . sprintf(esc_html__('Effective products (%s):', 'yoone-product-bundles'), '<span class="yoone-effective-count">' . (int) count($effective_allowed) . '</span>') . '</p>';
echo '<div id="yoone_effective_products_list">';
if (! empty($effective_allowed)) {
echo '<ul class="yoone-effective-list" style="max-height:220px; overflow:auto; margin:0; padding-left:1.5em; border:1px solid #ddd; background:#fff;">';
foreach ($effective_allowed as $pid) {
$p = wc_get_product($pid);
if ($p) {
printf('<li>%s <span style="color:#999;">(#%d)</span></li>', esc_html($p->get_formatted_name()), (int) $pid);
}
}
echo '</ul>';
} else {
echo '<p class="description">' . esc_html__('No products will be available with the current configuration.', 'yoone-product-bundles') . '</p>';
}
echo '</div>';
echo '</div>';
// Minimum bundle quantity // Minimum bundle quantity
woocommerce_wp_text_input(array( woocommerce_wp_text_input(array(
@ -158,6 +210,11 @@ class Yoone_Product_Bundles_Admin {
$min_qty = isset($_POST['yoone_bundle_min_quantity']) ? absint($_POST['yoone_bundle_min_quantity']) : 0; $min_qty = isset($_POST['yoone_bundle_min_quantity']) ? absint($_POST['yoone_bundle_min_quantity']) : 0;
update_post_meta($post_id, Yoone_Product_Bundles::META_MIN_QTY, max(0, $min_qty)); update_post_meta($post_id, Yoone_Product_Bundles::META_MIN_QTY, max(0, $min_qty));
// 保存选择模式include | exclude | all
$select_mode = isset($_POST['yoone_bundle_select_mode']) ? sanitize_text_field($_POST['yoone_bundle_select_mode']) : 'include';
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
update_post_meta($post_id, Yoone_Product_Bundles::META_SELECT_MODE, $select_mode);
// 保存 grouping taxonomy // 保存 grouping taxonomy
$group_taxonomy = isset($_POST['yoone_bundle_group_taxonomy']) ? sanitize_text_field($_POST['yoone_bundle_group_taxonomy']) : 'product_cat'; $group_taxonomy = isset($_POST['yoone_bundle_group_taxonomy']) ? sanitize_text_field($_POST['yoone_bundle_group_taxonomy']) : 'product_cat';
$group_taxonomy = in_array($group_taxonomy, array('product_cat','product_tag'), true) ? $group_taxonomy : 'product_cat'; $group_taxonomy = in_array($group_taxonomy, array('product_cat','product_tag'), true) ? $group_taxonomy : 'product_cat';
@ -177,23 +234,97 @@ class Yoone_Product_Bundles_Admin {
} }
check_ajax_referer('yoone-bundle-admin-nonce', 'security'); check_ajax_referer('yoone-bundle-admin-nonce', 'security');
$products = wc_get_products(array( // 返回所有已发布的 simple 产品包含任何目录可见性visible/hidden/exclude-from-catalog/search
'type' => 'simple', $product_ids = wc_get_products(array(
'status' => 'publish', 'type' => array('simple'),
'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1, 'limit' => -1,
'orderby' => 'title',
'order' => 'ASC',
'return' => 'ids',
)); ));
$results = array(); $results = array();
foreach ($products as $product) { if (is_array($product_ids)) {
foreach ($product_ids as $pid) {
$product = wc_get_product($pid);
if ($product) {
$results[] = array( $results[] = array(
'id' => $product->get_id(), 'id' => (int) $pid,
'text' => $product->get_formatted_name(), 'text' => $product->get_formatted_name(),
); );
} }
}
}
wp_send_json_success($results); wp_send_json_success($results);
} }
/**
* AJAX: 动态计算有效产品列表与总数
* 输入select_mode, allowed_products[](原始配置列表,不应用推导)
* 输出:{ items: [{id,text}], count: number }
*/
public function ajax_calc_effective_products() {
if (! current_user_can('edit_products')) {
wp_send_json_error('permission_denied', 403);
}
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
$select_mode = isset($_POST['select_mode']) ? sanitize_text_field($_POST['select_mode']) : 'include';
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
$allowed_raw = isset($_POST['allowed_products']) ? (array) $_POST['allowed_products'] : array();
$allowed_raw = array_values(array_filter(array_map('absint', $allowed_raw)));
// 仅保留 simple 产品ID
if (! empty($allowed_raw)) {
$simple_only = array();
foreach ($allowed_raw as $aid) {
$p = wc_get_product($aid);
if ($p && $p->is_type('simple')) {
$simple_only[] = $aid;
}
}
$allowed_raw = $simple_only;
}
$effective_ids = array();
if ($select_mode === 'include') {
$effective_ids = $allowed_raw;
} else {
// exclude/all: 先取全部 simple 产品,再按模式处理
$all_ids = wc_get_products(array(
'type' => array('simple'),
'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1,
'return' => 'ids',
));
$all_ids = is_array($all_ids) ? array_values(array_map('absint', $all_ids)) : array();
if ($select_mode === 'all') {
$effective_ids = $all_ids;
} else { // exclude
$effective_ids = array_values(array_diff($all_ids, $allowed_raw));
}
}
$items = array();
foreach ($effective_ids as $pid) {
$p = wc_get_product($pid);
if ($p) {
$items[] = array(
'id' => (int) $pid,
'text' => $p->get_formatted_name(),
);
}
}
wp_send_json_success(array(
'items' => $items,
'count' => count($items),
));
}
/** /**
* AJAX: 根据选择的 taxonomy 返回术语列表 * AJAX: 根据选择的 taxonomy 返回术语列表
*/ */
@ -239,7 +370,3 @@ class Yoone_Product_Bundles_Admin {
wp_enqueue_style($handle_css); wp_enqueue_style($handle_css);
} }
} }
// 保存选择模式
$select_mode = isset($_POST['yoone_bundle_select_mode']) ? sanitize_text_field($_POST['yoone_bundle_select_mode']) : 'include';
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
update_post_meta($post_id, Yoone_Product_Bundles::META_SELECT_MODE, $select_mode);

View File

@ -83,10 +83,11 @@ class Yoone_Product_Bundles {
} }
// 根据选择模式计算最终 allowed 列表 // 根据选择模式计算最终 allowed 列表
if ($select_mode === 'all' || $select_mode === 'exclude') { if ($select_mode === 'all' || $select_mode === 'exclude') {
// 获取所有 simple 产品ID // 获取所有 simple 产品ID(包含任何目录可见性)
$all_ids = wc_get_products(array( $all_ids = wc_get_products(array(
'type' => array('simple'), 'type' => array('simple'),
'status' => array('publish'), 'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1, 'limit' => -1,
'return' => 'ids', 'return' => 'ids',
)); ));

View File

@ -34,6 +34,7 @@ if (empty($allowed_products) && in_array($select_mode, array('exclude','all'), t
$all_ids = wc_get_products(array( $all_ids = wc_get_products(array(
'type' => array('simple'), 'type' => array('simple'),
'status' => array('publish'), 'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1, 'limit' => -1,
'return' => 'ids', 'return' => 'ids',
)); ));