From 369ce3c65b3374a06c962e595d367110b6b63fcb Mon Sep 17 00:00:00 2001 From: tianyuan zhuo Date: Thu, 6 Nov 2025 18:10:04 +0800 Subject: [PATCH] Initial commit: Yoone Product Bundles plugin --- .gitignore | 46 ++++ README.md | 96 +++++++++ assets/css/admin.css | 3 + assets/css/frontend.css | 74 +++++++ assets/js/admin.js | 52 +++++ assets/js/frontend.js | 45 ++++ docs/技术文档.md | 0 docs/需求.md | 1 + docs/项目新增.md | 69 ++++++ .../class-yoone-product-bundles-admin.php | 154 ++++++++++++++ includes/class-yoone-product-bundles.php | 80 +++++++ includes/class-yoone-product-type-bundle.php | 52 +++++ .../class-yoone-product-bundles-frontend.php | 198 ++++++++++++++++++ templates/global/yoone-bundle-form.php | 110 ++++++++++ yoone-product-bundles.php | 67 ++++++ 15 files changed, 1047 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/css/admin.css create mode 100644 assets/css/frontend.css create mode 100644 assets/js/admin.js create mode 100644 assets/js/frontend.js create mode 100644 docs/技术文档.md create mode 100644 docs/需求.md create mode 100644 docs/项目新增.md create mode 100644 includes/admin/class-yoone-product-bundles-admin.php create mode 100644 includes/class-yoone-product-bundles.php create mode 100644 includes/class-yoone-product-type-bundle.php create mode 100644 includes/frontend/class-yoone-product-bundles-frontend.php create mode 100644 templates/global/yoone-bundle-form.php create mode 100644 yoone-product-bundles.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ded9b39 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..faba754 --- /dev/null +++ b/README.md @@ -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` + +这些插件实现更复杂的组合逻辑与定价;本插件以轻量的方式提供一个可扩展的基础框架。 \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..9711c72 --- /dev/null +++ b/assets/css/admin.css @@ -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; } \ No newline at end of file diff --git a/assets/css/frontend.css b/assets/css/frontend.css new file mode 100644 index 0000000..1cdf242 --- /dev/null +++ b/assets/css/frontend.css @@ -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; +} \ No newline at end of file diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..2563f26 --- /dev/null +++ b/assets/js/admin.js @@ -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); \ No newline at end of file diff --git a/assets/js/frontend.js b/assets/js/frontend.js new file mode 100644 index 0000000..dfd2590 --- /dev/null +++ b/assets/js/frontend.js @@ -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); \ No newline at end of file diff --git a/docs/技术文档.md b/docs/技术文档.md new file mode 100644 index 0000000..e69de29 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/docs/项目新增.md b/docs/项目新增.md new file mode 100644 index 0000000..8183e20 --- /dev/null +++ b/docs/项目新增.md @@ -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 兼容性 +- 前端兼容性测试(响应式设计) +- 性能测试(大数据量场景) + +请按照以上需求实现插件,保持代码结构清晰并确保所有功能点都有详细注释说明实现逻辑。 \ 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 new file mode 100644 index 0000000..ae7fd8a --- /dev/null +++ b/includes/admin/class-yoone-product-bundles-admin.php @@ -0,0 +1,154 @@ + __('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 '
'; + wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field'); + + 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 '

'; + echo ''; + // Search only for products, not variations, to avoid errors + echo ''; + echo '' . esc_html__('Only simple products are supported. Variable products may be supported in a future version.', 'yoone-product-bundles') . ''; + echo '

'; + + // 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 '

'; + 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 '

'; + + echo '
'; // options_group + echo '
'; // 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); + } +} \ No newline at end of file diff --git a/includes/class-yoone-product-bundles.php b/includes/class-yoone-product-bundles.php new file mode 100644 index 0000000..e5f8771 --- /dev/null +++ b/includes/class-yoone-product-bundles.php @@ -0,0 +1,80 @@ + + const META_MIN_QTY = '_yoone_bundle_min_quantity'; // int + const META_CATEGORIES = '_yoone_bundle_categories'; // array 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, + ); + } +} \ No newline at end of file diff --git a/includes/class-yoone-product-type-bundle.php b/includes/class-yoone-product-type-bundle.php new file mode 100644 index 0000000..8c1613a --- /dev/null +++ b/includes/class-yoone-product-type-bundle.php @@ -0,0 +1,52 @@ +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. + } +} \ No newline at end of file diff --git a/includes/frontend/class-yoone-product-bundles-frontend.php b/includes/frontend/class-yoone-product-bundles-frontend.php new file mode 100644 index 0000000..0dee662 --- /dev/null +++ b/includes/frontend/class-yoone-product-bundles-frontend.php @@ -0,0 +1,198 @@ +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; + } +} \ No newline at end of file diff --git a/templates/global/yoone-bundle-form.php b/templates/global/yoone-bundle-form.php new file mode 100644 index 0000000..99057cb --- /dev/null +++ b/templates/global/yoone-bundle-form.php @@ -0,0 +1,110 @@ +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)); +} +// --- 数据准备逻辑结束 --- +?> + +
+
+
+

+

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/yoone-product-bundles.php b/yoone-product-bundles.php new file mode 100644 index 0000000..78698f0 --- /dev/null +++ b/yoone-product-bundles.php @@ -0,0 +1,67 @@ +

' . esc_html__('Yoone Product Bundles requires WooCommerce to be activated.', 'yoone-product-bundles') . '

'; + }); + 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 表,可在此创建。 +}); \ No newline at end of file