diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 8522df1..ae4374c 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -5,4 +5,72 @@ .yoone-bundle-table { width: 100%; border-collapse: collapse; } .yoone-bundle-table th, .yoone-bundle-table td { border-bottom: 1px solid #eee; padding: 8px; } .yoone-bundle-meta { display: flex; gap: 1em; align-items: center; } -.yoone-bundle-actions { margin-top: 1em; } \ No newline at end of file +.yoone-bundle-actions { margin-top: 1em; } + +/* 在混装产品页面隐藏默认产品图片区域(兼容常见主题结构) */ +.product.type-yoone_bundle div.images, +.single-product .product.type-yoone_bundle .images { + display: none !important; +} +/* 兼容通过 body_class 添加的标记 */ +.yoone-bundle-product div.images { display: none !important; } + +/* 表格列宽优化 */ +.yoone-bundle-table th:nth-child(3), +.yoone-bundle-table td:nth-child(3) { width: 160px; } + +/* 强制混装产品页面的内容区域为全宽,以容纳卡片布局 */ +.yoone-bundle-product-page .summary { + width: 100% !important; /* 覆盖主题默认的宽度限制 */ + float: none !important; /* 清除浮动 */ +} + +.yoone-bundle-add-to-cart-button { + margin-top: 1em; +} + +/* 新的卡片式布局样式 */ +.yoone-bundle-items-wrapper { + display: flex; + flex-wrap: wrap; + gap: 20px; /* 卡片之间的间距 */ +} + +.yoone-bundle-item-card { + display: flex; + flex-direction: column; + align-items: center; /* 垂直居中对齐 */ + width: 150px; /* 卡片宽度,可根据需要调整 */ + border: 1px solid #eee; + border-radius: 8px; + padding: 10px; + text-align: center; + transition: box-shadow 0.3s; +} + +.yoone-bundle-item-card:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} + +.yoone-bundle-item-card .item-image img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +.yoone-bundle-item-card .item-title { + font-size: 14px; + margin: 8px 0; + font-weight: bold; +} + +.yoone-bundle-item-card .item-price { + font-size: 16px; + color: #c00; + margin-bottom: 8px; +} + +.yoone-bundle-item-card .item-quantity input { + width: 80px; + text-align: center; +} \ No newline at end of file diff --git a/assets/js/admin.js b/assets/js/admin.js index 94aa59f..2563f26 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,3 +1,52 @@ -(function($){ - // 预留:后台增强脚本(例如联动过滤 simple products、校验最小数量等)。 +(function($) { + $(document).ready(function() { + // “一键添加所有 Simple Product” 按钮点击事件 + $(document).on('click', '.yoone-add-all-simple-products', function(e) { + e.preventDefault(); + + var $button = $(this); + var $select = $('select.wc-product-search[name="yoone_bundle_allowed_products[]"]'); + var nonce = $('#yoone_bundle_admin_nonce_field').val(); + + $button.prop('disabled', true).text('正在加载...'); + + $.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'); + } + }); + }); + }); })(jQuery); \ No newline at end of file diff --git a/docs/需求.md b/docs/需求.md new file mode 100644 index 0000000..294d919 --- /dev/null +++ b/docs/需求.md @@ -0,0 +1 @@ +- 混装产品可批量添加simple product为混装可选商品 \ 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 cf9402e..8ba9389 100644 --- a/includes/admin/class-yoone-product-bundles-admin.php +++ b/includes/admin/class-yoone-product-bundles-admin.php @@ -19,6 +19,11 @@ class Yoone_Product_Bundles_Admin { add_action('woocommerce_product_data_panels', array($this, 'render_product_data_panel')); // 保存配置 add_action('woocommerce_admin_process_product_meta', array($this, 'save_product_meta')); + add_action('woocommerce_process_product_meta', array($this, 'save_product_meta')); + add_action('save_post_product', array($this, 'save_product_meta'), 15); + + // AJAX: 获取所有 simple product + add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products')); } public function add_product_data_tab($tabs) { @@ -39,13 +44,17 @@ class Yoone_Product_Bundles_Admin { $min_qty = $config['min_qty']; $cats = $config['categories']; - echo '
' . esc_html__('配置可混装的简单商品、最小混装数量以及前端展示的分类分组。', 'yoone-product-bundles') . '
' . esc_html__('可混装商品', 'yoone-product-bundles') . ''; - echo ''; + echo '' . esc_html__('一键添加所有 Simple Product', 'yoone-product-bundles') . ''; + // 仅搜索产品,不含变体,避免误选导致前端不显示 + echo ''; if (! empty($allowed)) { foreach ($allowed as $pid) { $p = wc_get_product($pid); @@ -86,12 +95,25 @@ class Yoone_Product_Bundles_Admin { } public function save_product_meta($post_id) { - $product = wc_get_product($post_id); - if (! $product || $product->get_type() !== Yoone_Product_Bundles::TYPE) return; + // 无论当前 product 对象类型为何,只要提交了我们的字段,就进行保存。 + // 这可以避免在首次切换产品类型时由于保存顺序问题导致配置未写入。 + $has_fields = isset($_POST['yoone_bundle_allowed_products']) || isset($_POST['yoone_bundle_min_quantity']) || isset($_POST['yoone_bundle_categories']); + if (! $has_fields) return; // 保存 allowed products $allowed = isset($_POST['yoone_bundle_allowed_products']) ? (array) $_POST['yoone_bundle_allowed_products'] : array(); $allowed = array_values(array_filter(array_map('absint', $allowed))); + // 仅保留 simple 产品ID + if (! empty($allowed)) { + $simple_only = array(); + foreach ($allowed as $aid) { + $p = wc_get_product($aid); + if ($p && $p->is_type('simple')) { + $simple_only[] = $aid; + } + } + $allowed = $simple_only; + } update_post_meta($post_id, Yoone_Product_Bundles::META_ALLOWED_PRODUCTS, $allowed); // 保存 min qty @@ -103,4 +125,30 @@ class Yoone_Product_Bundles_Admin { $cats = array_values(array_filter(array_map('absint', $cats))); update_post_meta($post_id, Yoone_Product_Bundles::META_CATEGORIES, $cats); } + + /** + * AJAX handler: 获取所有已发布的 simple product + */ + public function ajax_get_all_simple_products() { + if (! current_user_can('edit_products')) { + wp_send_json_error('permission_denied', 403); + } + check_ajax_referer('yoone-bundle-admin-nonce', 'security'); + + $products = wc_get_products(array( + 'type' => 'simple', + 'status' => 'publish', + 'limit' => -1, + )); + + $results = array(); + foreach ($products as $product) { + $results[] = array( + 'id' => $product->get_id(), + 'text' => $product->get_formatted_name(), + ); + } + + wp_send_json_success($results); + } } \ No newline at end of file diff --git a/includes/class-yoone-product-bundles.php b/includes/class-yoone-product-bundles.php index 22929c5..2784c74 100644 --- a/includes/class-yoone-product-bundles.php +++ b/includes/class-yoone-product-bundles.php @@ -58,7 +58,18 @@ class Yoone_Product_Bundles { $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); + // 仅保留 simple 产品(避免后台选择了变体或其他类型导致前端不显示) $allowed = is_array($allowed) ? array_values(array_map('absint', $allowed)) : array(); + if (! empty($allowed)) { + $simple_only = array(); + foreach ($allowed as $aid) { + $p = wc_get_product($aid); + if ($p && $p->is_type('simple')) { + $simple_only[] = $aid; + } + } + $allowed = $simple_only; + } $cats = is_array($cats) ? array_values(array_map('absint', $cats)) : array(); return array( 'allowed_products' => $allowed, diff --git a/includes/frontend/class-yoone-product-bundles-frontend.php b/includes/frontend/class-yoone-product-bundles-frontend.php index 84ae66b..248baab 100644 --- a/includes/frontend/class-yoone-product-bundles-frontend.php +++ b/includes/frontend/class-yoone-product-bundles-frontend.php @@ -5,16 +5,19 @@ defined('ABSPATH') || exit; class Yoone_Product_Bundles_Frontend { - protected static $instance = null; + + private static $_instance = null; public static function instance() { - if (null === self::$instance) self::$instance = new self(); - return self::$instance; + if (is_null(self::$_instance)) { + self::$_instance = new self(); + } + return self::$_instance; } private function __construct() { - // 用我们的模板替换默认的 add-to-cart 按钮 - add_action('woocommerce_' . Yoone_Product_Bundles::TYPE . '_add_to_cart', array($this, 'render_add_to_cart')); // 显示表单 + // 加载自定义的混装产品页面模板 + add_filter('template_include', array($this, 'use_bundle_template'), 99); // 处理加入购物车:校验数量并打包所选组件数据 add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 10, 6); @@ -25,109 +28,152 @@ class Yoone_Product_Bundles_Frontend { // 结算前动态调整价格 add_action('woocommerce_before_calculate_totals', array($this, 'adjust_bundle_price'), 20, 1); + + // 为混装产品页增加 body class,便于主题兼容样式控制 + add_filter('body_class', array($this, 'filter_body_class')); + + // 从 woocommerce_single_product_summary 钩子中移除默认的 add-to-cart + add_action('init', array($this, 'remove_default_add_to_cart')); } /** - * 渲染自定义表单:按所选分类分组显示允许的简单商品,提供数量输入。 + * 移除默认的 add-to-cart 按钮,因为我们的模板会自己处理 */ - public function render_add_to_cart() { - wc_get_template('single-product/add-to-cart/yoone-bundle.php', array(), '', YOONE_PB_PATH . 'templates/'); + public function remove_default_add_to_cart() { + remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30); } /** - * 校验:总选择数量 >= min_qty + * 如果是混装产品页面,则加载我们的自定义模板。 */ - public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id, $variations, $cart_item_data) { + public function use_bundle_template($template) { + if (is_singular('product')) { + global $post; + $product = wc_get_product($post->ID); + if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) { + // 加载插件内的完整页面模板 + $new_template = YOONE_PB_PATH . 'templates/single-product-yoone-bundle.php'; + if (file_exists($new_template)) { + // 加载前端资源 + if (wp_style_is('yoone-pb-frontend', 'registered')) wp_enqueue_style('yoone-pb-frontend'); + if (wp_script_is('yoone-pb-frontend', 'registered')) wp_enqueue_script('yoone-pb-frontend'); + return $new_template; + } + } + } + return $template; + } + + /** + * 在混装产品页增加 body class。 + */ + public function filter_body_class($classes) { + global $post; + if (is_singular('product') && $post) { + $product = wc_get_product($post->ID); + if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) { + $classes[] = 'yoone-bundle-product'; + } + } + return $classes; + } + + /** + * 校验混装产品加入购物车:至少选择一个组件。 + */ + public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = null, $variations = null, $cart_item_data = null) { $product = wc_get_product($product_id); - if (! $product || $product->get_type() !== Yoone_Product_Bundles::TYPE) return $passed; + if ($product->get_type() !== Yoone_Product_Bundles::TYPE) { + return $passed; + } $config = Yoone_Product_Bundles::get_bundle_config($product); - $min = max(0, absint($config['min_qty'])); + $min_qty = max(1, absint($config['min_qty'])); - // 从 POST 中收集数量 - $selected = isset($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array(); + $components = !empty($_POST['yoone_bundle_components']) ? $_POST['yoone_bundle_components'] : array(); $total_qty = 0; - foreach ($selected as $pid => $qty) { + foreach ($components as $comp_id => $qty) { $qty = absint($qty); - if ($qty > 0) $total_qty += $qty; + if ($qty > 0) { + $total_qty += $qty; + } } - if ($total_qty < $min) { - wc_add_notice(sprintf(__('请至少选择 %d 件混装组件后再加入购物车。', 'yoone-product-bundles'), $min), 'error'); + if ($total_qty < $min_qty) { + wc_add_notice(sprintf(__('您需要至少选择 %d 件商品才能将此混装包加入购物车。', 'yoone-product-bundles'), $min_qty), 'error'); return false; } + return $passed; } /** - * 将所选组件数据存入购物车项。 + * 将混装组件数据添加到购物车项目。 */ public function add_cart_item_data($cart_item_data, $product_id, $variation_id) { $product = wc_get_product($product_id); - if (! $product || $product->get_type() !== Yoone_Product_Bundles::TYPE) return $cart_item_data; + if ($product->get_type() !== Yoone_Product_Bundles::TYPE) { + return $cart_item_data; + } - $selected = isset($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array(); - // 仅保留大于 0 的数量 - $components = array(); - foreach ($selected as $pid => $qty) { - $pid = absint($pid); - $qty = absint($qty); - if ($pid && $qty > 0) $components[$pid] = $qty; - } - if (! empty($components)) { - $cart_item_data['yoone_bundle_components'] = $components; + if (isset($_POST['yoone_bundle_components'])) { + $components = array(); + foreach ($_POST['yoone_bundle_components'] as $comp_id => $qty) { + $qty = absint($qty); + if ($qty > 0) { + $components[absint($comp_id)] = $qty; + } + } + if (!empty($components)) { + $cart_item_data['yoone_bundle_components'] = $components; + } } + return $cart_item_data; } /** - * 在购物车行项目中展示所选组件摘要。 + * 在购物车和订单中显示混装组件的摘要。 */ public function display_cart_item_data($item_data, $cart_item) { - if (! empty($cart_item['yoone_bundle_components'])) { - $lines = array(); - foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) { - $p = wc_get_product($pid); - if ($p) { - $lines[] = sprintf('%s × %d', $p->get_name(), $qty); - } - } - if (! empty($lines)) { - $item_data[] = array( - 'key' => __('混装内容', 'yoone-product-bundles'), - 'value' => implode("\n", $lines), - 'display' => implode('', array_map('esc_html', $lines)), - ); + if (empty($cart_item['yoone_bundle_components'])) { + return $item_data; + } + + $value = ""; + foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) { + $product = wc_get_product($pid); + if ($product) { + $value .= $product->get_name() . ' × ' . $qty . "; "; } } + + $item_data[] = array( + 'key' => __('混装明细', 'yoone-product-bundles'), + 'value' => rtrim($value, "; "), + 'display' => '' + ); + return $item_data; } /** - * 结算前根据组件动态调整混装产品的行项目价格(= 所选简单商品单价 × 数量 的总和)。 - * 注意:这里使用的是产品当前价格(不含税的基础价),是否包含税由 WooCommerce 税设置决定。 + * 在将商品添加到购物车之前,根据所选组件动态计算混装产品的总价。 */ public function adjust_bundle_price($cart) { - if (is_admin() && ! defined('DOING_AJAX')) { - return; // 后台不处理 - } - if (empty($cart) || ! method_exists($cart, 'get_cart')) return; + if (is_admin() && !defined('DOING_AJAX')) return; foreach ($cart->get_cart() as $cart_item_key => $cart_item) { - if (empty($cart_item['yoone_bundle_components'])) continue; - $product = isset($cart_item['data']) ? $cart_item['data'] : null; - if (! $product || ! is_a($product, 'WC_Product')) continue; - if ($product->get_type() !== Yoone_Product_Bundles::TYPE) continue; - - $total = 0.0; - foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) { - $p = wc_get_product($pid); - if (! $p) continue; - $unit = floatval($p->get_price()); // 基础价 - $total += $unit * absint($qty); + if (isset($cart_item['yoone_bundle_components'])) { + $total_price = 0; + foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) { + $product = wc_get_product($pid); + if ($product) { + $total_price += (float) $product->get_price() * $qty; + } + } + $cart_item['data']->set_price($total_price); } - // 将产品行项目价格设为总价 - $product->set_price($total); } } } \ No newline at end of file diff --git a/templates/single-product-yoone-bundle.php b/templates/single-product-yoone-bundle.php new file mode 100644 index 0000000..429464f --- /dev/null +++ b/templates/single-product-yoone-bundle.php @@ -0,0 +1,144 @@ +get_type() !== Yoone_Product_Bundles::TYPE) { + // 如果不是,则回退到标准模板 + wc_get_template_part('content', 'single-product'); + return; +} + +// --- 数据准备逻辑 --- +$config = Yoone_Product_Bundles::get_bundle_config($product); +$allowed = $config['allowed_products']; +$min_qty = max(0, absint($config['min_qty'])); +$cat_ids = $config['categories']; + +$allowed_products = array(); +foreach ($allowed as $pid) { + $p = wc_get_product($pid); + if ($p && $p->is_type('simple')) { + $allowed_products[$pid] = $p; + } +} + +$groups = array(); +if (!empty($cat_ids)) { + $others = array(); + foreach ($allowed_products as $pid => $p) { + $terms = get_the_terms($pid, 'product_cat'); + $matched = false; + if (is_array($terms)) { + foreach ($terms as $t) { + if (in_array($t->term_id, $cat_ids, true)) { + if (!isset($groups[$t->term_id])) { + $groups[$t->term_id] = array('term' => $t, 'items' => array()); + } + $groups[$t->term_id]['items'][] = $p; + $matched = true; + } + } + } + if (!$matched) $others[] = $p; + } + if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others); +} else { + $groups[0] = array('term' => null, 'items' => array_values($allowed_products)); +} +// --- 数据准备逻辑结束 --- + +get_header('shop'); +?> + + + +> + + + + + + + + + + + 0 + + + + + + $group) : ?> + + + name); ?> + + + + + + + + + + get_id(); + $thumbnail = $item->get_image('woocommerce_thumbnail'); + ?> + + + + + + + + get_name()); ?> + + + get_price_html()); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/single-product/add-to-cart/yoone-bundle.php b/templates/single-product/add-to-cart/yoone-bundle.php deleted file mode 100644 index f410eac..0000000 --- a/templates/single-product/add-to-cart/yoone-bundle.php +++ /dev/null @@ -1,125 +0,0 @@ -get_type() !== Yoone_Product_Bundles::TYPE) { - return; -} - -$config = Yoone_Product_Bundles::get_bundle_config($product); -$allowed = $config['allowed_products']; -$min_qty = max(0, absint($config['min_qty'])); -$cat_ids = $config['categories']; - -// 收集允许的商品对象,且必须是 simple product -$allowed_products = array(); -foreach ($allowed as $pid) { - $p = wc_get_product($pid); - if ($p && $p->is_type('simple')) { - $allowed_products[$pid] = $p; - } -} - -// 按分类分组(如果未配置分类,则全部在一个组内) -$groups = array(); -if (! empty($cat_ids)) { - foreach ($allowed_products as $pid => $p) { - $terms = get_the_terms($pid, 'product_cat'); - $matched = false; - if (is_array($terms)) { - foreach ($terms as $t) { - if (in_array($t->term_id, $cat_ids, true)) { - $groups[$t->term_id]['term'] = $t; - $groups[$t->term_id]['items'][] = $p; - $matched = true; - } - } - } - // 若商品不属于所选分类,则不显示 - if (! $matched) { - // 跳过 - } - } -} else { - $groups[0] = array('term' => null, 'items' => array_values($allowed_products)); -} - -// 表单开始 -?> - - - - 0 - - - - - - $group) : ?> - - - name); ?> - - - - - - - - - - - - - - - get_id(); ?> - - - get_name()); ?> - - - get_price_html()); ?> - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/yoone-product-bundles.php b/yoone-product-bundles.php index fc782c4..8418ed1 100644 --- a/yoone-product-bundles.php +++ b/yoone-product-bundles.php @@ -38,10 +38,13 @@ add_action('plugins_loaded', function () { Yoone_Product_Bundles_Frontend::instance(); }); +// 插件版本号 +define('YOONE_PB_VERSION', '0.1.1'); + // 资源加载(前端样式/脚本) add_action('wp_enqueue_scripts', function () { - wp_register_style('yoone-pb-frontend', YOONE_PB_URL . 'assets/css/frontend.css', array(), '0.1.0'); - wp_register_script('yoone-pb-frontend', YOONE_PB_URL . 'assets/js/frontend.js', array('jquery'), '0.1.0', true); + wp_register_style('yoone-pb-frontend', YOONE_PB_URL . 'assets/css/frontend.css', array(), YOONE_PB_VERSION); + wp_register_script('yoone-pb-frontend', YOONE_PB_URL . 'assets/js/frontend.js', array('jquery'), YOONE_PB_VERSION, true); }); // 后台资源 @@ -52,7 +55,8 @@ add_action('admin_enqueue_scripts', function ($hook) { if ($screen && 'product' === $screen->post_type) { wp_enqueue_style('woocommerce_admin_styles'); wp_enqueue_script('wc-product-search'); // Woo 的 select2 产品搜索 - wp_enqueue_style('yoone-pb-admin', YOONE_PB_URL . 'assets/css/admin.css', array(), '0.1.0'); + wp_enqueue_style('yoone-pb-admin', YOONE_PB_URL . 'assets/css/admin.css', array(), YOONE_PB_VERSION); + wp_enqueue_script('yoone-pb-admin', YOONE_PB_URL . 'assets/js/admin.js', array('jquery'), YOONE_PB_VERSION, true); } } });
0