Initial commit: Yoone Product Bundles plugin

This commit is contained in:
tianyuan zhuo 2025-11-06 18:10:04 +08:00
commit 369ce3c65b
15 changed files with 1047 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Composer
/vendor/
composer.phar
composer.lock
# npm
node_modules/
npm-debug.log
package-lock.json
# WordPress
*.log
wp-config-local.php
wp-config.php
# IDE
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
*.swp
*.swo
# Environment
.env
.env.local
.env.*.local
# Testing
/phpunit.xml
/tests/_output/
/tests/_support/_generated/
# Build files
*.zip
*.tar.gz
*.rar

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# Yoone Product Bundles (混装产品)
为 WooCommerce 提供一个基础的“混装产品Bundle”类型
- 后台为某个产品配置:可混装的简单商品列表、最小混装数量、展示用的分类分组;
- 前端在该混装产品的产品页显示一个表单,按分类分组列出可混装商品,用户为每个商品填写数量;
- 当总数量达到或者超过“最小混装数量”时,允许加入购物车;
- 购物车中该混装产品的行项目价格 = 所选简单商品单价 × 数量 的总和;
- 购物车与订单行项目显示所选组件的摘要;
本插件是一个可运行的 MVP结构清晰、注释完整方便后续扩展动态价格、库存校验、分组规则、变体商品支持、打包折扣等
## 安装与启用
1. 将插件目录 `yoone-product-bundles` 放置到站点的 `wp-content/plugins/` 下。
2. 在 WordPress 后台 -> 插件 中启用 “Yoone Product Bundles (混装产品)”。
3. 确保 WooCommerce 已安装并启用。
## 使用步骤(后台)
1. 新建或编辑一个产品,在“产品类型”下拉中选择 “混装产品 (Yoone Bundle)”。
2. 在出现的 “混装产品” 标签页中配置:
- 可混装商品:搜索并选择多个 simple product
- 最小混装数量:顾客选择的总数需不小于此值;
- 展示分类:用于在前端按分类分组显示可混装商品(仅显示属于所选分类的商品)。
3. 发布或更新该产品。
## 前端体验
访问该混装产品的产品页:
- 页面按分类分组列出可混装商品,并显示每个商品的价格;
- 用户为每个商品填写数量,页面实时展示“当前选择数量”;
- 当选择总数达到最小混装数量后,“加入购物车”按钮自动可用;
- 加入购物车后,购物车中该混装产品的价格为所选商品价格总和,并显示所选内容的摘要;
## 技术实现概览
- 产品类型注册:`Yoone_Product_Bundles` 在 `product_type_selector` 中注册类型标识 `yoone_bundle`,并通过 `woocommerce_product_class` 映射到 `WC_Product_Yoone_Bundle`
- 后台配置面板:`Yoone_Product_Bundles_Admin` 在产品编辑页添加一个标签页与面板,使用 WooCommerce 原生的 `wc-product-search` 组件搜索选择产品;数据保存在以下 postmeta
- `_yoone_bundle_allowed_products` (int[]) 可混装的产品ID列表
- `_yoone_bundle_min_quantity` (int) 最小混装数量;
- `_yoone_bundle_categories` (int[]) 用于分组显示的 product_cat 分类ID列表
- 前端表单与购物车:`Yoone_Product_Bundles_Frontend`
- 通过 `woocommerce_yoone_bundle_add_to_cart` 钩子渲染模板 `templates/single-product/add-to-cart/yoone-bundle.php`
- 在 `woocommerce_add_to_cart_validation` 中校验总数量是否达到最小值;
- 在 `woocommerce_add_cart_item_data` 中把所选组件pid=>qty存入购物车项
- 在 `woocommerce_get_item_data` 中展示购物车行项目的组件摘要;
- 在 `woocommerce_before_calculate_totals` 中根据组件动态设置该行项目价格(基础价,不含税),确保结算金额正确;
## 目录结构
```
yoone-product-bundles/
├── yoone-product-bundles.php // 插件入口
├── includes/
│ ├── class-yoone-product-bundles.php // 核心:类型注册、读取配置
│ ├── class-yoone-product-type-bundle.php // 产品类型对象 WC_Product_Yoone_Bundle
│ ├── admin/
│ │ └── class-yoone-product-bundles-admin.php
│ └── frontend/
│ └── class-yoone-product-bundles-frontend.php
├── templates/
│ └── single-product/add-to-cart/yoone-bundle.php // 前端表单模板
└── assets/
├── css/{admin.css, frontend.css}
└── js/{admin.js, frontend.js}
```
## 钩子与扩展点
- 读取配置:`Yoone_Product_Bundles::get_bundle_config($product)`,返回 `allowed_products/min_qty/categories`
- 可在 `Yoone_Product_Bundles_Frontend::adjust_bundle_price()` 中替换价格计算逻辑,例如引入折扣、不同计价规则;
- 可添加库存校验:在 `validate_add_to_cart()` 中检验每个组件的可售数量;
- 可将组件拆分为独立行项目:在 `add_to_cart` 阶段为每个组件添加到购物车,并将 bundle 项设为 0 价(当前实现为一个行项目合计价格)。
## 常见问题
1. 为什么“加入购物车”不可用?
- 需要先达到“最小混装数量”;
- 或者后台未配置可混装商品。
2. 价格是否含税?
- 当前以 `get_price()` 基础价计算总价,不含税;实际显示与结算是否含税取决于 WooCommerce 税设置。可在价格调整钩子中自定义。
3. 是否支持变体商品?
- MVP 仅支持 simple product后续可扩展到变体商品与多层组合。
## 参考实现
可参考以下现有插件:
- `woocommerce-product-bundles_v8.5.2`
- `wpc-composite-products-premium_v7.6.2`
- `woocommerce-composite-products_v11.0.1`
- `woo-product-bundle-premium_v8.3.5`
这些插件实现更复杂的组合逻辑与定价;本插件以轻量的方式提供一个可扩展的基础框架。

3
assets/css/admin.css Normal file
View File

@ -0,0 +1,3 @@
/* Yoone Product Bundles 后台样式(简版) */
#yoone_bundle_data .description { color: #666; display: block; margin-top: 6px; }
#yoone_bundle_data .form-field { margin-bottom: 12px; }

74
assets/css/frontend.css Normal file
View File

@ -0,0 +1,74 @@
/* Yoone Product Bundles 前端样式(简版) */
.yoone-bundle-form { margin-top: 1em; }
.yoone-bundle-group { margin-bottom: 1.5em; }
.yoone-bundle-group-title { margin: 0.5em 0; font-weight: 600; }
.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; }
/* 在混装产品页面隐藏默认产品图片区域(兼容常见主题结构) */
.product.type-yoone_bundle div.images,
.single-product .product.type-yoone_bundle .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: 1em;
margin: 1em;
}
.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;
margin-bottom: 8px;
}
.yoone-bundle-item-card .item-quantity input {
width: 80px;
text-align: center;
}

52
assets/js/admin.js Normal file
View File

@ -0,0 +1,52 @@
(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);

45
assets/js/frontend.js Normal file
View File

@ -0,0 +1,45 @@
(function($) {
'use strict';
$(function() {
var minQty = 0;
var wrapper = $('.yoone-bundle-form');
// 从 DOM 中获取最小数量
var minText = wrapper.find('.yoone-bundle-min').text();
if (minText) {
var match = minText.match(/\d+/);
if (match) {
minQty = parseInt(match[0], 10);
}
}
function updateState() {
var total = 0;
wrapper.find('.yoone-bundle-qty').each(function() {
var v = parseInt($(this).val(), 10);
if (!isNaN(v) && v > 0) {
total += v;
}
});
// 更新显示的数量
wrapper.find('.yoone-bundle-selected-count').text(total);
// 根据是否满足最小数量来启用/禁用按钮
var btn = wrapper.find('.single_add_to_cart_button');
if (total >= minQty) {
btn.prop('disabled', false);
} else {
btn.prop('disabled', true);
}
}
// 绑定事件
wrapper.on('change keyup', '.yoone-bundle-qty', updateState);
// 页面加载时立即执行一次,以确保初始状态正确
updateState();
});
})(jQuery);

0
docs/技术文档.md Normal file
View File

1
docs/需求.md Normal file
View File

@ -0,0 +1 @@
- 混装产品可批量添加simple product为混装可选商品

69
docs/项目新增.md Normal file
View File

@ -0,0 +1,69 @@
开发一个名为 `yoone-product-bundles` 的 WooCommerce 插件,实现混装产品管理功能。以下是详细需求说明和技术实现方案:
1. 插件基础框架:
- 创建标准的 WordPress 插件目录结构
- 包含主插件文件 `yoone-product-bundles.php` 并添加必要的插件头信息
- 实现 WooCommerce 插件激活/卸载钩子
- 建立国际化支持textdomain: yoone-product-bundles
2. 产品类型管理:
- 注册新的产品类型 'bundle'
- 实现产品类型类继承 WC_Product
- 添加必要的产品数据存储字段
- 创建产品编辑界面的 metaboxes
1. 混装产品配置:
- 后台配置界面:
* 可添加的 simple product 选择器(支持多选)
* 每个产品的最小数量配置(正整数)
* 混装产品分类配置(支持多级分类)
* 价格计算规则配置(固定价或组件总和)
- 数据存储:
* 使用 WooCommerce 标准数据存储机制
* 创建必要的数据库表扩展
* 实现数据验证和清理
1. 前端功能:
- 混装产品页面模板:
* 显示配置的分类层级
* 每个产品项显示:
- 产品图片
- 产品名称
- 数量选择器(带最小值验证)
- 实时价格计算
* 加入购物车按钮(满足最小数量时启用)
- 购物车/订单显示:
* 显示混装产品组成明细
* 保持组件产品信息关联
1. 代码规范:
- 遵循 WordPress 编码标准
- 所有方法添加详细注释,包括:
* 功能说明
* 参数说明
* 返回值说明
* 涉及的 WooCommerce 钩子
- 关键操作添加日志记录
- 实现必要的安全验证nonce、权限检查等
1. 文档:
- 完整的 README.md 包含:
* 插件功能概述
* 安装说明
* 配置指南
* 截图示例
* 常见问题
- 代码内文档PHPDoc 标准)
1. 参考实现:
- woocommerce-product-bundles_v8.5.2
- wpc-composite-products-premium_v7.6.2
- woo-product-bundle-premium_v8.3.5
- yith-woocommerce-product-bundles-premium
1. 测试要求:
- 单元测试覆盖核心功能
- 集成测试验证 WooCommerce 兼容性
- 前端兼容性测试(响应式设计)
- 性能测试(大数据量场景)
请按照以上需求实现插件,保持代码结构清晰并确保所有功能点都有详细注释说明实现逻辑。

View File

@ -0,0 +1,154 @@
<?php
/**
* Admin: Provides the configuration interface for bundle products (allowed products, min quantity, categories).
*/
defined('ABSPATH') || exit;
class Yoone_Product_Bundles_Admin {
protected static $instance = null;
public static function instance() {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
private function __construct() {
// 添加一个产品数据标签页
add_filter('woocommerce_product_data_tabs', array($this, 'add_product_data_tab'));
// 对应的面板内容
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) {
$tabs['yoone_bundle'] = array(
'label' => __('Mix and Match', 'yoone-product-bundles'),
'target' => 'yoone_bundle_data',
'class' => array('show_if_yoone_bundle'), // Only show for 'yoone_bundle' type
'priority' => 70,
);
return $tabs;
}
public function render_product_data_panel() {
global $post;
$product = wc_get_product($post->ID);
$config = Yoone_Product_Bundles::get_bundle_config($product);
$allowed = $config['allowed_products'];
$min_qty = $config['min_qty'];
$cats = $config['categories'];
echo '<div id="yoone_bundle_data" class="panel woocommerce_options_panel show_if_yoone_bundle">';
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
echo '<div class="options_group">';
echo '<p>' . 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') . '</p>';
// Allowed products: use Woo's product search (select2), multiple
echo '<p class="form-field"><label>' . esc_html__('Allowed Products', 'yoone-product-bundles') . '</label>';
echo '<button type="button" class="button yoone-add-all-simple-products" style="margin-left: 10px;">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
// Search only for products, not variations, to avoid errors
echo '<select class="wc-product-search" multiple style="width: 90%;" 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)) {
foreach ($allowed as $pid) {
$p = wc_get_product($pid);
if ($p) {
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
}
}
}
echo '</select>';
echo '<span class="description">' . esc_html__('Only simple products are supported. Variable products may be supported in a future version.', 'yoone-product-bundles') . '</span>';
echo '</p>';
// Minimum bundle quantity
woocommerce_wp_text_input(array(
'id' => 'yoone_bundle_min_quantity',
'label' => __('Minimum Quantity', 'yoone-product-bundles'),
'type' => 'number',
'desc_tip' => true,
'description' => __('The total quantity of selected items must be greater than or equal to this value to add to the cart.', 'yoone-product-bundles'),
'value' => $min_qty,
'custom_attributes' => array('min' => '0'),
));
// Display categories (product_cat)
echo '<p class="form-field"><label>' . esc_html__('Display Categories', 'yoone-product-bundles') . '</label>';
echo '<select class="wc-enhanced-select" multiple style="width: 90%;" name="yoone_bundle_categories[]" data-placeholder="' . esc_attr__('Select categories to group products by…', 'yoone-product-bundles') . '">';
$terms = get_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
foreach ($terms as $t) {
$selected = in_array($t->term_id, $cats, true) ? 'selected' : '';
printf('<option value="%d" %s>%s</option>', $t->term_id, $selected, esc_html($t->name));
}
echo '</select>';
echo '<span class="description">' . esc_html__('Group the allowed products by category on the frontend. Only products matching the selected categories will be shown.', 'yoone-product-bundles') . '</span>';
echo '</p>';
echo '</div>'; // options_group
echo '</div>'; // panel
}
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']);
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
$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);
}
/**
* 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);
}
}

View File

@ -0,0 +1,80 @@
<?php
/**
* Core: Registers the product type, constants, and utility methods.
*/
defined('ABSPATH') || exit;
class Yoone_Product_Bundles {
const TYPE = 'yoone_bundle';
// Post meta keys for configuration
const META_ALLOWED_PRODUCTS = '_yoone_bundle_allowed_products'; // array<int>
const META_MIN_QTY = '_yoone_bundle_min_quantity'; // int
const META_CATEGORIES = '_yoone_bundle_categories'; // array<int> product_cat term_ids
protected static $instance = null;
public static function instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
// Add "Mix and Match" to the "Product Type" dropdown in the admin.
add_filter('product_type_selector', array($this, 'register_product_type_in_selector'));
// Map the type to our class name, so WooCommerce instantiates the correct product object.
add_filter('woocommerce_product_class', array($this, 'map_product_class'), 10, 2);
}
/**
* Add "Mix and Match" to the "Product Type" dropdown in the admin.
*/
public function register_product_type_in_selector($types) {
$types[self::TYPE] = __('Mix and Match (Yoone Bundle)', 'yoone-product-bundles');
return $types;
}
/**
* Map the type to our class name, so WooCommerce instantiates the correct product object.
*/
public function map_product_class($classname, $product_type) {
if ($product_type === self::TYPE) {
$classname = 'WC_Product_Yoone_Bundle';
}
return $classname;
}
/**
* Read and normalize the bundle configuration.
* @param int|WC_Product $product
* @return array{allowed_products:int[],min_qty:int,categories:int[]}
*/
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());
$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);
// 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)) {
$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,
'min_qty' => max(0, $min),
'categories' => $cats,
);
}
}

View File

@ -0,0 +1,52 @@
<?php
/**
* Product Type Object: WC_Product_Yoone_Bundle
* - Defines the product type identifier;
* - Can be configured to set purchasing rules (e.g., sold individually).
*/
defined('ABSPATH') || exit;
class WC_Product_Yoone_Bundle extends WC_Product {
public function __construct($product) {
parent::__construct($product);
// A bundle product itself isn't shipped; shipping is determined by its components (MVP simplification).
$this->set_virtual(true);
}
public function get_type() {
return Yoone_Product_Bundles::TYPE;
}
/**
* Bundle products are typically sold individually as they contain multiple items.
*/
public function is_sold_individually() {
return true;
}
// --- Price Override ---
public function get_price($context = 'view') {
return 0;
}
public function get_regular_price($context = 'view') {
return 0;
}
public function get_sale_price($context = 'view') {
return 0;
}
public function set_price($price) {
// Prevent price from being changed.
}
public function set_regular_price($price) {
// Prevent price from being changed.
}
public function set_sale_price($price) {
// Prevent price from being changed.
}
}

View File

@ -0,0 +1,198 @@
<?php
/**
* 前端:为混装产品渲染自定义的“添加到购物车”表单,并处理验证和加入购物车的逻辑。
*/
defined('ABSPATH') || exit;
class Yoone_Product_Bundles_Frontend {
private static $_instance = null;
public static function instance() {
if (is_null(self::$_instance)) {
self::$_instance = new self();
}
return self::$_instance;
}
private function __construct() {
$this->setup_hooks();
}
/**
* 设置所有与前端相关的钩子。
*/
public function setup_hooks() {
// 处理混装产品的自定义“添加到购物车”流程
add_filter('woocommerce_add_to_cart_validation', array($this, 'process_bundle_add_to_cart'), 10, 3);
// 当混装容器被移除时,移除其子项目
add_action('woocommerce_cart_item_removed', array($this, 'remove_bundle_children'), 10, 2);
// 隐藏购物车中子项目的“移除”链接
add_filter('woocommerce_cart_item_remove_link', array($this, 'hide_child_remove_link'), 10, 2);
// 在购物车中,显示子项目所属的混装产品
add_filter('woocommerce_get_item_data', array($this, 'display_child_bundle_link'), 10, 2);
// 为混装产品页面添加 body class以便于样式化
add_filter('body_class', array($this, 'filter_body_class'));
// 用我们的自定义表单替换默认的“添加到购物车”按钮
add_action('woocommerce_single_product_summary', array($this, 'remove_default_add_to_cart_for_bundle'), 29);
add_action('woocommerce_single_product_summary', array($this, 'render_bundle_add_to_cart_form'), 30);
}
/**
* 劫持混装产品的“添加到购物车”流程。
* 验证提交,然后将容器和所有子产品添加到购物车。
*
* @return bool False 以阻止默认的“添加到购物车”操作。
*/
public function process_bundle_add_to_cart($passed, $product_id, $quantity) {
$product = wc_get_product($product_id);
if (!$product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
return $passed; // 不是我们的产品,直接通过。
}
// 1. 验证
$config = Yoone_Product_Bundles::get_bundle_config($product);
$min_qty = max(1, absint($config['min_qty']));
$components = !empty($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array();
$total_qty = 0;
$clean_components = array();
foreach ($components as $comp_id => $qty) {
$qty = absint($qty);
if ($qty > 0) {
$total_qty += $qty;
$clean_components[absint($comp_id)] = $qty;
}
}
if ($total_qty < $min_qty) {
wc_add_notice(sprintf(__('You need to select at least %d items to add this bundle to your cart.', 'yoone-product-bundles'), $min_qty), 'error');
return false;
}
// 2. 添加商品到购物车
try {
$bundle_container_id = uniqid('bundle_');
// 添加主混装产品作为价格为0的容器
WC()->cart->add_to_cart($product_id, 1, 0, array(), array('yoone_bundle_container_id' => $bundle_container_id));
// 添加子组件
foreach ($clean_components as $comp_id => $qty) {
WC()->cart->add_to_cart($comp_id, $qty, 0, array(), array(
'yoone_bundle_parent_id' => $bundle_container_id,
'yoone_bundle_parent_product_id' => $product_id
));
}
// 设置成功消息并重定向
wc_add_to_cart_message(array($product_id => 1), true);
add_filter('woocommerce_add_to_cart_redirect', function() { return wc_get_cart_url(); });
} catch (Exception $e) {
wc_add_notice($e->getMessage(), 'error');
return false;
}
// 阻止主产品的默认“添加到购物车”行为
return false;
}
/**
* 当一个购物车项目被移除时,检查它是否是一个混装容器。
* 如果是,则找到并移除其所有的子项目。
*/
public function remove_bundle_children($removed_cart_item_key, $cart) {
// 此钩子在项目已从购物车会话中移除后触发,所以我们在 `removed_cart_contents` 中查找它。
if (!isset($cart->removed_cart_contents[$removed_cart_item_key])) {
return;
}
$removed_item = $cart->removed_cart_contents[$removed_cart_item_key];
// 检查被移除的商品是否是我们的混装容器。
if (isset($removed_item['yoone_bundle_container_id'])) {
$bundle_container_id = $removed_item['yoone_bundle_container_id'];
$child_item_keys_to_remove = [];
// 遍历购物车,找到所有属于此容器的子项目。
// 我们不能在遍历时直接修改购物车所以我们先收集要移除的项目的key。
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
if (isset($cart_item['yoone_bundle_parent_id']) && $cart_item['yoone_bundle_parent_id'] === $bundle_container_id) {
$child_item_keys_to_remove[] = $cart_item_key;
}
}
// 现在遍历收集到的key并从购物车中移除子项目。
foreach ($child_item_keys_to_remove as $key_to_remove) {
$cart->remove_cart_item($key_to_remove);
}
}
}
/**
* 隐藏混装产品子项目的移除链接。
*/
public function hide_child_remove_link($link, $cart_item_key) {
$cart_item = WC()->cart->get_cart_item($cart_item_key);
if (isset($cart_item['yoone_bundle_parent_id'])) {
return '';
}
return $link;
}
/**
* 对于子项目,在购物车中添加一个元信息行,以链接回父混装产品。
*/
public function display_child_bundle_link($item_data, $cart_item) {
if (isset($cart_item['yoone_bundle_parent_product_id'])) {
$parent_product = wc_get_product($cart_item['yoone_bundle_parent_product_id']);
if ($parent_product) {
$item_data[] = array(
'key' => __('Part of', 'yoone-product-bundles'),
'value' => $parent_product->get_name(),
);
}
}
return $item_data;
}
/**
* 仅为混装产品移除默认的“添加到购物车”按钮。
*/
public function remove_default_add_to_cart_for_bundle() {
global $product;
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30);
}
}
/**
* 为混装产品渲染自定义的“添加到购物车”表单。
*/
public function render_bundle_add_to_cart_form() {
global $product;
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
wc_get_template('global/yoone-bundle-form.php', array(), '', YOONE_PB_PATH . 'templates/');
}
}
/**
* 为混装产品页面添加一个 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;
}
}

View File

@ -0,0 +1,110 @@
<?php
/**
* Yoone Product Bundles - Mix and Match Product Selection Form
*
* This template part contains the full frontend interface for selecting bundle components,
* including the card-based layout, quantity inputs, and dynamically updated total count.
* It is hooked into `woocommerce_single_product_summary` to replace the standard add-to-cart form.
*
* @version 3.0.1
*/
defined('ABSPATH') || exit;
global $product;
// --- 数据准备逻辑 ---
$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));
}
// --- 数据准备逻辑结束 ---
?>
<div class="yoone-bundle-form-container">
<form class="cart yoone-bundle-form" action="<?php echo esc_url(apply_filters('woocommerce_add_to_cart_form_action', $product->get_permalink())); ?>" method="post" enctype="multipart/form-data">
<div class="yoone-bundle-meta">
<p class="yoone-bundle-min"><?php printf(esc_html__('Select at least %d items to build your bundle.', 'yoone-product-bundles'), $min_qty); ?></p>
<p class="yoone-bundle-selected"><?php esc_html_e('Currently selected:', 'yoone-product-bundles'); ?> <span class="yoone-bundle-selected-count">0</span></p>
<div class="yoone-bundle-actions">
<input type="hidden" name="add-to-cart" value="<?php echo esc_attr($product->get_id()); ?>" />
<button type="submit" class="single_add_to_cart_button button alt" disabled><?php esc_html_e('Add to Cart', 'yoone-product-bundles'); ?></button>
</div>
</div>
<?php if (empty($allowed_products)) : ?>
<p><?php esc_html_e('No products are available for this bundle. Please configure it in the backend.', 'yoone-product-bundles'); ?></p>
<?php else : ?>
<?php foreach ($groups as $gid => $group) : ?>
<div class="yoone-bundle-group">
<?php if (!empty($group['term'])) : ?>
<h3 class="yoone-bundle-group-title"><?php echo esc_html($group['term']->name); ?></h3>
<?php else : ?>
<h3 class="yoone-bundle-group-title"><?php esc_html_e('Available Products', 'yoone-product-bundles'); ?></h3>
<?php endif; ?>
<?php if (empty($group['items'])) : ?>
<p class="yoone-bundle-no-items"><?php esc_html_e('No products available in this group.', 'yoone-product-bundles'); ?></p>
<?php else : ?>
<div class="yoone-bundle-items-wrapper">
<?php foreach ($group['items'] as $item) : ?>
<?php
$pid = $item->get_id();
$thumbnail = $item->get_image('woocommerce_thumbnail');
?>
<div class="yoone-bundle-item-card">
<div class="item-image">
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank">
<?php echo $thumbnail ? $thumbnail : wc_placeholder_img('woocommerce_thumbnail'); ?>
</a>
</div>
<h4 class="item-title">
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank"><?php echo esc_html($item->get_name()); ?></a>
</h4>
<div class="item-price">
<?php echo wp_kses_post($item->get_price_html()); ?>
</div>
<div class="item-quantity">
<input type="number" min="0" step="1" class="yoone-bundle-qty" name="yoone_bundle_components[<?php echo esc_attr($pid); ?>]" value="0" placeholder="0" />
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</form>
</div>

67
yoone-product-bundles.php Normal file
View File

@ -0,0 +1,67 @@
<?php
/**
* Plugin Name: Yoone Product Bundles
* Description: Adds a "Mix and Match" product type to WooCommerce. Allows creating a bundle product where customers can select from a list of simple products, set quantities, and add to cart if a minimum quantity is met. The price is the sum of the selected products.
* Author: Yoone
* Version: 0.1.0
* Requires at least: 6.0
* Requires PHP: 7.4
* WC requires at least: 6.0
* WC tested up to: 8.x
*/
defined('ABSPATH') || exit;
// Basic defense: ensure WooCommerce is active
if (! function_exists('WC')) {
add_action('admin_notices', function () {
echo '<div class="notice notice-error"><p>' . esc_html__('Yoone Product Bundles requires WooCommerce to be activated.', 'yoone-product-bundles') . '</p></div>';
});
return;
}
// Plugin constants (paths)
define('YOONE_PB_PATH', plugin_dir_path(__FILE__));
define('YOONE_PB_URL', plugin_dir_url(__FILE__));
// 自动加载(简单版):按需引入类文件
require_once YOONE_PB_PATH . 'includes/class-yoone-product-bundles.php';
require_once YOONE_PB_PATH . 'includes/class-yoone-product-type-bundle.php';
require_once YOONE_PB_PATH . 'includes/admin/class-yoone-product-bundles-admin.php';
require_once YOONE_PB_PATH . 'includes/frontend/class-yoone-product-bundles-frontend.php';
// 引导插件
add_action('plugins_loaded', function () {
// 注册产品类型
Yoone_Product_Bundles::instance();
Yoone_Product_Bundles_Admin::instance();
Yoone_Product_Bundles_Frontend::instance();
});
// 插件版本号
define('YOONE_PB_VERSION', '0.1.1');
// 资源加载(前端样式/脚本)
add_action('wp_enqueue_scripts', function () {
wp_enqueue_style('yoone-pb-frontend', YOONE_PB_URL . 'assets/css/frontend.css', array(), YOONE_PB_VERSION);
wp_enqueue_script('yoone-pb-frontend', YOONE_PB_URL . 'assets/js/frontend.js', array('jquery'), YOONE_PB_VERSION, true);
});
// 后台资源
add_action('admin_enqueue_scripts', function ($hook) {
// 仅在产品编辑页加载post.php/post-new.php 且 post_type=product
if (strpos($hook, 'post.php') !== false || strpos($hook, 'post-new.php') !== false) {
$screen = get_current_screen();
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(), YOONE_PB_VERSION);
wp_enqueue_script('yoone-pb-admin', YOONE_PB_URL . 'assets/js/admin.js', array('jquery'), YOONE_PB_VERSION, true);
}
}
});
// 激活钩子:无需建表,使用 postmeta 保存配置;此处可预置默认值或做数据迁移
register_activation_hook(__FILE__, function () {
// 预留:未来若需要自定义 DB 表,可在此创建。
});