diff --git a/assets/css/admin.css b/assets/css/admin.css
index 9711c72..02915c6 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -1,3 +1,18 @@
-/* Yoone Product Bundles 后台样式(简版) */
-#yoone_bundle_data .description { color: #666; display: block; margin-top: 6px; }
-#yoone_bundle_data .form-field { margin-bottom: 12px; }
\ No newline at end of file
+/* Yoone Product Bundles 后台样式优化 */
+#yoone_bundle_data { padding-top: 8px; }
+#yoone_bundle_data .form-field { margin-bottom: 16px; }
+/* 让标签独占一行,避免窄屏换行难看 */
+#yoone_bundle_data .form-field > label { display: block; font-weight: 600; margin-bottom: 6px; }
+/* 描述放到下一行,颜色更柔和 */
+#yoone_bundle_data .description { color: #666; display: block; margin-top: 6px; line-height: 1.4; }
+/* 选择器与搜索框占满一行 */
+#yoone_bundle_data .wc-product-search,
+#yoone_bundle_data .wc-enhanced-select,
+#yoone_bundle_data select,
+#yoone_bundle_data input[type="text"],
+#yoone_bundle_data input[type="number"] { width: 100% !important; max-width: 820px; }
+/* Select2 容器也撑满 */
+#yoone_bundle_data .select2-container { width: 100% !important; max-width: 820px; }
+/* 按钮的间距 */
+#yoone_bundle_data .yoone-add-all-simple-products { margin-left: 0; margin-bottom: 8px; }
+#yoone_bundle_data .yoone-clear-products-list { margin-left: 8px; margin-bottom: 8px; }
\ No newline at end of file
diff --git a/assets/js/admin.js b/assets/js/admin.js
index 2563f26..5dd185d 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -1,52 +1,85 @@
-(function($) {
- $(document).ready(function() {
- // “一键添加所有 Simple Product” 按钮点击事件
- $(document).on('click', '.yoone-add-all-simple-products', function(e) {
- e.preventDefault();
+/* Yoone Product Bundles - Admin dynamic grouping terms
+ * When switching grouping taxonomy (category/tag), refresh the terms select list accordingly.
+ */
+(function($){
+ $(function(){
+ var $tax = $('#yoone_bundle_group_taxonomy');
+ var $terms = $('#yoone_bundle_group_terms');
+ var $addAllBtn = $('.yoone-add-all-simple-products');
+ var $clearBtn = $('.yoone-clear-products-list');
+ var $productSelect = $('select[name="yoone_bundle_allowed_products[]"]');
- var $button = $(this);
- var $select = $('select.wc-product-search[name="yoone_bundle_allowed_products[]"]');
- var nonce = $('#yoone_bundle_admin_nonce_field').val();
+ if (typeof YoonePBAdmin === 'undefined') {
+ return;
+ }
- $button.prop('disabled', true).text('正在加载...');
+ function refreshTerms(tax) {
+ if (!tax) return;
+ $terms.prop('disabled', true);
+ $.post(YoonePBAdmin.ajax_url, {
+ action: 'yoone_get_taxonomy_terms',
+ taxonomy: tax,
+ security: YoonePBAdmin.security
+ }).done(function(resp){
+ if (resp && resp.success && Array.isArray(resp.data)) {
+ var items = resp.data;
+ $terms.empty();
+ items.forEach(function(item){
+ var opt = $(' ').val(item.id).text(item.text);
+ $terms.append(opt);
+ });
+ // re-init enhanced selects
+ $(document.body).trigger('wc-enhanced-select-init');
+ }
+ }).always(function(){
+ $terms.prop('disabled', false);
+ });
+ }
- $.ajax({
- url: ajaxurl,
- method: 'POST',
- data: {
- action: 'yoone_get_all_simple_products',
- security: nonce
- },
- success: function(response) {
- if (response.success) {
- var existing_ids = $select.val() || [];
- var products = response.data;
-
- products.forEach(function(product) {
- // 如果下拉列表中不存在该选项,则添加
- if ($select.find('option[value="' + product.id + '"]').length === 0) {
- var newOption = new Option(product.text, product.id, false, false);
- $select.append(newOption);
- }
- // 将新获取的商品ID加入到已选中的ID数组中
- if (existing_ids.indexOf(product.id.toString()) === -1) {
- existing_ids.push(product.id.toString());
- }
- });
-
- // 更新 select2 的选中状态
- $select.val(existing_ids).trigger('change');
- $button.prop('disabled', false).text('一键添加所有 Simple Product');
- } else {
- alert('加载失败,请重试。');
- $button.prop('disabled', false).text('一键添加所有 Simple Product');
- }
- },
- error: function() {
- alert('请求失败,请检查网络连接或联系管理员。');
- $button.prop('disabled', false).text('一键添加所有 Simple Product');
- }
- });
- });
+ $tax.on('change', function(){
+ var val = $(this).val();
+ refreshTerms(val);
});
+
+ // Add all simple products to the products select
+ if ($addAllBtn.length && $productSelect.length) {
+ $addAllBtn.on('click', function(){
+ var $btn = $(this);
+ $btn.prop('disabled', true);
+ $productSelect.prop('disabled', true);
+ $.post(YoonePBAdmin.ajax_url, {
+ action: 'yoone_get_all_simple_products',
+ security: YoonePBAdmin.security
+ }).done(function(resp){
+ if (resp && resp.success && Array.isArray(resp.data)) {
+ // Clear then add all as selected
+ $productSelect.empty();
+ resp.data.forEach(function(item){
+ var existing = $productSelect.find('option[value="'+item.id+'"]');
+ if (!existing.length) {
+ var opt = $(' ').val(item.id).text(item.text).prop('selected', true);
+ $productSelect.append(opt);
+ } else {
+ existing.prop('selected', true);
+ }
+ });
+ // Trigger change for select2/Woo product search
+ $productSelect.trigger('change');
+ }
+ }).always(function(){
+ $btn.prop('disabled', false);
+ $productSelect.prop('disabled', false);
+ });
+ });
+ }
+
+ // Clear products list quickly
+ if ($clearBtn.length && $productSelect.length) {
+ $clearBtn.on('click', function(){
+ $productSelect.find('option').prop('selected', false);
+ // For select2/Woo product search, simply trigger change after clearing selection
+ $productSelect.val([]).trigger('change');
+ });
+ }
+ });
})(jQuery);
\ No newline at end of file
diff --git a/includes/admin/class-yoone-product-bundles-admin.php b/includes/admin/class-yoone-product-bundles-admin.php
index ae7fd8a..55cfdff 100644
--- a/includes/admin/class-yoone-product-bundles-admin.php
+++ b/includes/admin/class-yoone-product-bundles-admin.php
@@ -24,6 +24,11 @@ class Yoone_Product_Bundles_Admin {
// AJAX: 获取所有 simple product
add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products'));
+ // AJAX: 根据所选 taxonomy 获取术语列表
+ add_action('wp_ajax_yoone_get_taxonomy_terms', array($this, 'ajax_get_taxonomy_terms'));
+
+ // 后台资源
+ add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets'));
}
public function add_product_data_tab($tabs) {
@@ -42,7 +47,9 @@ class Yoone_Product_Bundles_Admin {
$config = Yoone_Product_Bundles::get_bundle_config($product);
$allowed = $config['allowed_products'];
$min_qty = $config['min_qty'];
- $cats = $config['categories'];
+ $select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
+ $group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
+ $group_terms = isset($config['group_terms']) ? (array) $config['group_terms'] : array();
echo '
';
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
@@ -50,11 +57,28 @@ class Yoone_Product_Bundles_Admin {
echo '
';
echo '
' . esc_html__('Configure the simple products that can be included in the bundle, the minimum quantity, and the category grouping for the frontend display.', 'yoone-product-bundles') . '
';
- // Allowed products: use Woo's product search (select2), multiple
- echo '
' . esc_html__('Allowed Products', 'yoone-product-bundles') . ' ';
- echo '' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . ' ';
+ // Selection mode
+ woocommerce_wp_select(array(
+ 'id' => 'yoone_bundle_select_mode',
+ 'name' => 'yoone_bundle_select_mode',
+ 'label' => __('Selection Mode', 'yoone-product-bundles'),
+ 'description' => __('Choose how products are selected for the bundle: include list, exclude list, or allow all simple products.', 'yoone-product-bundles'),
+ 'desc_tip' => true,
+ 'options' => array(
+ 'include' => __('Include (only listed products)', 'yoone-product-bundles'),
+ 'exclude' => __('Exclude (allow all except listed)', 'yoone-product-bundles'),
+ 'all' => __('All (allow all simple products)', 'yoone-product-bundles'),
+ ),
+ 'value' => $select_mode,
+ ));
+
+ // Allowed/Excluded products: use Woo's product search (select2), multiple
+ echo '
' . esc_html__('Products List', 'yoone-product-bundles') . ' ';
+ echo '' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . ' ';
+ echo ' ';
+ echo '' . esc_html__('Clear Products List', 'yoone-product-bundles') . ' ';
// Search only for products, not variations, to avoid errors
- echo '';
+ echo '';
if (! empty($allowed)) {
foreach ($allowed as $pid) {
$p = wc_get_product($pid);
@@ -64,7 +88,8 @@ class Yoone_Product_Bundles_Admin {
}
}
echo ' ';
- echo '' . esc_html__('Only simple products are supported. Variable products may be supported in a future version.', 'yoone-product-bundles') . ' ';
+ // Dynamic description based on selection mode
+ echo '' . esc_html__('Simple products only. Include: only listed; Exclude: allow all except listed; All: ignore the list.', 'yoone-product-bundles') . ' ';
echo '
';
// Minimum bundle quantity
@@ -78,16 +103,29 @@ class Yoone_Product_Bundles_Admin {
'custom_attributes' => array('min' => '0'),
));
- // Display categories (product_cat)
- echo '
' . esc_html__('Display Categories', 'yoone-product-bundles') . ' ';
- echo '';
- $terms = get_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
+ // Grouping taxonomy (product_cat or product_tag)
+ woocommerce_wp_select(array(
+ 'id' => 'yoone_bundle_group_taxonomy',
+ 'label' => __('Grouping Taxonomy', 'yoone-product-bundles'),
+ 'description' => __('Choose which taxonomy to group products by: category or tag.', 'yoone-product-bundles'),
+ 'desc_tip' => true,
+ 'options' => array(
+ 'product_cat' => __('Category', 'yoone-product-bundles'),
+ 'product_tag' => __('Tag', 'yoone-product-bundles'),
+ ),
+ 'value' => $group_taxonomy,
+ ));
+
+ // Display terms of selected taxonomy
+ echo '' . esc_html__('Display Terms', 'yoone-product-bundles') . ' ';
+ echo '';
+ $terms = get_terms(array('taxonomy' => $group_taxonomy, 'hide_empty' => false));
foreach ($terms as $t) {
- $selected = in_array($t->term_id, $cats, true) ? 'selected' : '';
+ $selected = in_array($t->term_id, $group_terms, true) ? 'selected' : '';
printf('%s ', $t->term_id, $selected, esc_html($t->name));
}
echo ' ';
- echo '' . esc_html__('Group the allowed products by category on the frontend. Only products matching the selected categories will be shown.', 'yoone-product-bundles') . ' ';
+ echo '' . esc_html__('Group the allowed products by the selected taxonomy terms on the frontend. Only products matching the selected terms will be shown.', 'yoone-product-bundles') . ' ';
echo '
';
echo '
'; // options_group
@@ -97,7 +135,7 @@ class Yoone_Product_Bundles_Admin {
public function save_product_meta($post_id) {
// 无论当前 product 对象类型为何,只要提交了我们的字段,就进行保存。
// 这可以避免在首次切换产品类型时由于保存顺序问题导致配置未写入。
- $has_fields = isset($_POST['yoone_bundle_allowed_products']) || isset($_POST['yoone_bundle_min_quantity']) || isset($_POST['yoone_bundle_categories']);
+ $has_fields = isset($_POST['yoone_bundle_allowed_products']) || isset($_POST['yoone_bundle_min_quantity']) || isset($_POST['yoone_bundle_group_taxonomy']) || isset($_POST['yoone_bundle_group_terms']) || isset($_POST['yoone_bundle_select_mode']);
if (! $has_fields) return;
// 保存 allowed products
@@ -120,10 +158,14 @@ class Yoone_Product_Bundles_Admin {
$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));
- // 保存 categories
- $cats = isset($_POST['yoone_bundle_categories']) ? (array) $_POST['yoone_bundle_categories'] : array();
- $cats = array_values(array_filter(array_map('absint', $cats)));
- update_post_meta($post_id, Yoone_Product_Bundles::META_CATEGORIES, $cats);
+ // 保存 grouping taxonomy
+ $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';
+ update_post_meta($post_id, Yoone_Product_Bundles::META_GROUP_TAXONOMY, $group_taxonomy);
+ // 保存选择的术语
+ $terms = isset($_POST['yoone_bundle_group_terms']) ? (array) $_POST['yoone_bundle_group_terms'] : array();
+ $terms = array_values(array_filter(array_map('absint', $terms)));
+ update_post_meta($post_id, Yoone_Product_Bundles::META_GROUP_TERMS, $terms);
}
/**
@@ -151,4 +193,53 @@ class Yoone_Product_Bundles_Admin {
wp_send_json_success($results);
}
-}
\ No newline at end of file
+
+ /**
+ * AJAX: 根据选择的 taxonomy 返回术语列表
+ */
+ public function ajax_get_taxonomy_terms() {
+ if (! current_user_can('edit_products')) {
+ wp_send_json_error('permission_denied', 403);
+ }
+ check_ajax_referer('yoone-bundle-admin-nonce', 'security');
+
+ $taxonomy = isset($_POST['taxonomy']) ? sanitize_text_field($_POST['taxonomy']) : 'product_cat';
+ if (! in_array($taxonomy, array('product_cat', 'product_tag'), true)) {
+ wp_send_json_error(array('message' => 'invalid_taxonomy'));
+ }
+
+ $terms = get_terms(array('taxonomy' => $taxonomy, 'hide_empty' => false));
+ $results = array();
+ foreach ($terms as $t) {
+ $results[] = array('id' => (int) $t->term_id, 'text' => $t->name);
+ }
+ wp_send_json_success($results);
+ }
+
+ /**
+ * 后台资源:注入脚本以在切换分组 taxonomy 时动态刷新术语选择
+ */
+ public function enqueue_admin_assets($hook) {
+ // 仅在产品编辑页面加载
+ if ($hook !== 'post.php' && $hook !== 'post-new.php') return;
+ $screen = get_current_screen();
+ if (! $screen || $screen->post_type !== 'product') return;
+
+ $handle_js = 'yoone-pb-admin';
+ wp_register_script($handle_js, plugins_url('assets/js/admin.js', dirname(__FILE__, 3) . '/yoone-product-bundles.php'), array('jquery'), '1.0.0', true);
+ wp_enqueue_script($handle_js);
+ wp_localize_script($handle_js, 'YoonePBAdmin', array(
+ 'ajax_url' => admin_url('admin-ajax.php'),
+ 'security' => wp_create_nonce('yoone-bundle-admin-nonce'),
+ ));
+
+ // Enqueue CSS for nicer spacing & layout
+ $handle_css = 'yoone-pb-admin-css';
+ wp_register_style($handle_css, plugins_url('assets/css/admin.css', dirname(__FILE__, 3) . '/yoone-product-bundles.php'), array(), '1.0.1');
+ 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);
\ No newline at end of file
diff --git a/includes/class-yoone-product-bundles.php b/includes/class-yoone-product-bundles.php
index e5f8771..11fa1f6 100644
--- a/includes/class-yoone-product-bundles.php
+++ b/includes/class-yoone-product-bundles.php
@@ -10,7 +10,13 @@ class Yoone_Product_Bundles {
// Post meta keys for configuration
const META_ALLOWED_PRODUCTS = '_yoone_bundle_allowed_products'; // array
const META_MIN_QTY = '_yoone_bundle_min_quantity'; // int
- const META_CATEGORIES = '_yoone_bundle_categories'; // array product_cat term_ids
+ // 选择模式:include(仅包含列表)、exclude(排除列表,其余全部允许)、all(全部 simple 产品)
+ const META_SELECT_MODE = '_yoone_bundle_select_mode'; // 'include' | 'exclude' | 'all'
+ // 旧版:仅支持分类(product_cat)。
+ const META_CATEGORIES = '_yoone_bundle_categories'; // array product_cat term_ids(兼容旧数据)
+ // 新版:支持按 taxonomy(product_cat 或 product_tag)进行分组显示
+ const META_GROUP_TAXONOMY = '_yoone_bundle_group_taxonomy'; // 'product_cat' | 'product_tag'
+ const META_GROUP_TERMS = '_yoone_bundle_group_terms'; // array 选中的术语ID(来自所选taxonomy)
protected static $instance = null;
@@ -53,11 +59,16 @@ class Yoone_Product_Bundles {
*/
public static function get_bundle_config($product) {
$product = is_numeric($product) ? wc_get_product($product) : $product;
- if (! $product) return array('allowed_products' => array(), 'min_qty' => 0, 'categories' => array());
+ if (! $product) return array('allowed_products' => array(), 'min_qty' => 0, 'group_taxonomy' => 'product_cat', 'group_terms' => array());
$pid = $product->get_id();
$allowed = get_post_meta($pid, self::META_ALLOWED_PRODUCTS, true);
$min = absint(get_post_meta($pid, self::META_MIN_QTY, true));
- $cats = get_post_meta($pid, self::META_CATEGORIES, true);
+ $select_mode = get_post_meta($pid, self::META_SELECT_MODE, true);
+ $select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
+ // 读取新的分组设置
+ $group_tax = get_post_meta($pid, self::META_GROUP_TAXONOMY, true);
+ $group_terms = get_post_meta($pid, self::META_GROUP_TERMS, true);
+ $group_tax = in_array($group_tax, array('product_cat','product_tag'), true) ? $group_tax : 'product_cat';
// Keep only simple products (to avoid issues if variations or other types were selected in the backend)
$allowed = is_array($allowed) ? array_values(array_map('absint', $allowed)) : array();
if (! empty($allowed)) {
@@ -70,11 +81,37 @@ class Yoone_Product_Bundles {
}
$allowed = $simple_only;
}
- $cats = is_array($cats) ? array_values(array_map('absint', $cats)) : array();
+ // 根据选择模式计算最终 allowed 列表
+ if ($select_mode === 'all' || $select_mode === 'exclude') {
+ // 获取所有 simple 产品ID
+ $all_ids = wc_get_products(array(
+ 'type' => array('simple'),
+ 'status' => array('publish'),
+ 'limit' => -1,
+ 'return' => 'ids',
+ ));
+ $all_ids = is_array($all_ids) ? array_values(array_map('absint', $all_ids)) : array();
+ if ($select_mode === 'all') {
+ $allowed = $all_ids; // 全部 simple 产品
+ } else {
+ // exclude 模式:从全部 simple 产品中排除配置列表
+ $allowed = array_values(array_diff($all_ids, $allowed));
+ }
+ } // include 模式:保持 allowed 原样
+ // 兼容旧版:如果未配置新术语,但存在旧的分类设置,则沿用分类
+ if (empty($group_terms)) {
+ $cats = get_post_meta($pid, self::META_CATEGORIES, true);
+ $group_terms = is_array($cats) ? array_values(array_map('absint', $cats)) : array();
+ $group_tax = 'product_cat';
+ } else {
+ $group_terms = is_array($group_terms) ? array_values(array_map('absint', $group_terms)) : array();
+ }
return array(
'allowed_products' => $allowed,
'min_qty' => max(0, $min),
- 'categories' => $cats,
+ 'select_mode' => $select_mode,
+ 'group_taxonomy' => $group_tax,
+ 'group_terms' => $group_terms,
);
}
}
\ No newline at end of file
diff --git a/templates/global/yoone-bundle-form.php b/templates/global/yoone-bundle-form.php
index 99057cb..6b5f95f 100644
--- a/templates/global/yoone-bundle-form.php
+++ b/templates/global/yoone-bundle-form.php
@@ -16,8 +16,11 @@ global $product;
// --- 数据准备逻辑 ---
$config = Yoone_Product_Bundles::get_bundle_config($product);
$allowed = $config['allowed_products'];
+$select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
$min_qty = max(0, absint($config['min_qty']));
-$cat_ids = $config['categories'];
+// 读取按 taxonomy 分组的配置(支持 product_cat / product_tag)
+$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
+$term_ids = isset($config['group_terms']) ? (array) $config['group_terms'] : array();
$allowed_products = array();
foreach ($allowed as $pid) {
@@ -26,16 +29,34 @@ foreach ($allowed as $pid) {
$allowed_products[$pid] = $p;
}
}
+// 兜底:如果为 exclude/all 模式,但由于缓存或其他原因导致 allowed_products 为空,则直接读取所有 simple 产品
+if (empty($allowed_products) && in_array($select_mode, array('exclude','all'), true)) {
+ $all_ids = wc_get_products(array(
+ 'type' => array('simple'),
+ 'status' => array('publish'),
+ 'limit' => -1,
+ 'return' => 'ids',
+ ));
+ if (is_array($all_ids)) {
+ foreach ($all_ids as $pid) {
+ $p = wc_get_product($pid);
+ if ($p && $p->is_type('simple')) {
+ $allowed_products[$pid] = $p;
+ }
+ }
+ }
+}
$groups = array();
-if (!empty($cat_ids)) {
+if (!empty($term_ids)) {
+ $is_grouped = true;
$others = array();
foreach ($allowed_products as $pid => $p) {
- $terms = get_the_terms($pid, 'product_cat');
+ $terms = get_the_terms($pid, $group_taxonomy);
$matched = false;
if (is_array($terms)) {
foreach ($terms as $t) {
- if (in_array($t->term_id, $cat_ids, true)) {
+ if (in_array($t->term_id, $term_ids, true)) {
if (!isset($groups[$t->term_id])) {
$groups[$t->term_id] = array('term' => $t, 'items' => array());
}
@@ -48,6 +69,7 @@ if (!empty($cat_ids)) {
}
if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others);
} else {
+ $is_grouped = false;
$groups[0] = array('term' => null, 'items' => array_values($allowed_products));
}
// --- 数据准备逻辑结束 ---
@@ -63,6 +85,12 @@ if (!empty($cat_ids)) {
+
+