feat(订阅): 实现多订阅计划功能并支持混装产品

- 在后台添加多订阅计划管理界面,支持添加、删除和配置多个订阅计划
- 扩展订阅功能以支持混装产品类型,避免子项目重复计价
- 新增订阅计划数据存储和规范化处理逻辑
- 前端订阅逻辑适配混装容器价格计算
This commit is contained in:
tikkhun 2025-11-07 16:06:46 +08:00
parent 94d76c9c10
commit c2067450cb
4 changed files with 160 additions and 4 deletions

View File

@ -1,3 +1,30 @@
(function($){
// 预留:后台联动逻辑(例如启用订阅后才显示其他字段)。
$(function(){
var $tbody = $('#yoone-sub-plans-body');
var $addBtn = $('#yoone-sub-plan-add');
if ($tbody.length && $addBtn.length) {
$addBtn.on('click', function(){
var row = [
'<tr class="yoone-sub-plan-row">',
'<td><input type="text" name="yoone_sub_plans[label][]" placeholder="',
(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderLabel : '例如:标准计划'),
'" /></td>',
'<td><select name="yoone_sub_plans[period][]">',
'<option value="month">', (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.month : '月'), '</option>',
'<option value="year">', (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.year : '年'), '</option>',
'</select></td>',
'<td><input type="text" name="yoone_sub_plans[price][]" placeholder="', (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderPrice : '留空表示用产品价'), '" /></td>',
'<td><input type="number" min="0" step="0.1" name="yoone_sub_plans[discount_percent][]" placeholder="', (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderDiscount : '如10 表示9折'), '" /></td>',
'<td><button type="button" class="button yoone-sub-plan-remove">', (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.remove : '删除'), '</button></td>',
'</tr>'
].join('');
$tbody.append(row);
});
$tbody.on('click', '.yoone-sub-plan-remove', function(){
$(this).closest('tr').remove();
});
}
});
})(jQuery);

View File

@ -28,7 +28,8 @@ class Yoone_Subscriptions_Admin {
$tabs['yoone_subscriptions'] = array(
'label' => __('订阅计划', 'yoone-subscriptions'),
'target' => 'yoone_subscriptions_data',
'class' => array('show_if_simple', 'show_if_variable'),
// 在 simple、variable 以及 yoone_bundle混装产品类型上显示
'class' => array('show_if_simple', 'show_if_variable', 'show_if_yoone_bundle'),
'priority' => 80,
);
return $tabs;
@ -43,7 +44,8 @@ class Yoone_Subscriptions_Admin {
$cfg = Yoone_Subscriptions::get_config($product);
wp_nonce_field('yoone_subscriptions_save', 'yoone_subscriptions_nonce');
echo '<div id="yoone_subscriptions_data" class="panel woocommerce_options_panel hidden">';
// 面板在 simple/variable/yoone_bundle 上显示
echo '<div id="yoone_subscriptions_data" class="panel woocommerce_options_panel hidden show_if_simple show_if_variable show_if_yoone_bundle">';
echo '<div class="options_group">';
// 启用订阅
@ -117,6 +119,49 @@ class Yoone_Subscriptions_Admin {
'value' => $cfg['allow_onetime'] ? 'yes' : 'no',
));
// 多订阅计划管理
echo '<hr />';
echo '<h4>' . esc_html__('订阅计划列表', 'yoone-subscriptions') . '</h4>';
echo '<p class="description">' . esc_html__('可以为该产品配置多个订阅计划,前端用户可在购买时选择其一。每个计划可设置周期与每周期价格,或以折扣百分比表示相对产品价的优惠。', 'yoone-subscriptions') . '</p>';
$plans = isset($cfg['plans']) && is_array($cfg['plans']) ? $cfg['plans'] : array();
echo '<table class="widefat fixed" style="margin-top:10px;">';
echo '<thead><tr>'
. '<th>' . esc_html__('计划名称', 'yoone-subscriptions') . '</th>'
. '<th>' . esc_html__('周期', 'yoone-subscriptions') . '</th>'
. '<th>' . esc_html__('每周期价格', 'yoone-subscriptions') . '</th>'
. '<th>' . esc_html__('折扣百分比(可选)', 'yoone-subscriptions') . '</th>'
. '<th>' . esc_html__('操作', 'yoone-subscriptions') . '</th>'
. '</tr></thead><tbody id="yoone-sub-plans-body">';
if (! empty($plans)) {
foreach ($plans as $idx => $p) {
echo '<tr class="yoone-sub-plan-row">';
echo '<td><input type="text" name="yoone_sub_plans[label][]" value="' . esc_attr($p['label']) . '" placeholder="' . esc_attr__('例如:标准计划', 'yoone-subscriptions') . '" /></td>';
echo '<td><select name="yoone_sub_plans[period][]">'
. '<option value="month"' . selected($p['period'], 'month', false) . '>' . esc_html__('月', 'yoone-subscriptions') . '</option>'
. '<option value="year"' . selected($p['period'], 'year', false) . '>' . esc_html__('年', 'yoone-subscriptions') . '</option>'
. '</select></td>';
echo '<td><input type="text" name="yoone_sub_plans[price][]" value="' . esc_attr($p['price']) . '" placeholder="' . esc_attr__('留空表示用产品价', 'yoone-subscriptions') . '" /></td>';
echo '<td><input type="number" min="0" step="0.1" name="yoone_sub_plans[discount_percent][]" value="' . esc_attr($p['discount_percent']) . '" placeholder="' . esc_attr__('如10 表示9折', 'yoone-subscriptions') . '" /></td>';
echo '<td><button type="button" class="button yoone-sub-plan-remove">' . esc_html__('删除', 'yoone-subscriptions') . '</button></td>';
echo '</tr>';
}
} else {
// 初始空行
echo '<tr class="yoone-sub-plan-row">'
. '<td><input type="text" name="yoone_sub_plans[label][]" placeholder="' . esc_attr__('例如:标准计划', 'yoone-subscriptions') . '" /></td>'
. '<td><select name="yoone_sub_plans[period][]">'
. '<option value="month">' . esc_html__('月', 'yoone-subscriptions') . '</option>'
. '<option value="year">' . esc_html__('年', 'yoone-subscriptions') . '</option>'
. '</select></td>'
. '<td><input type="text" name="yoone_sub_plans[price][]" placeholder="' . esc_attr__('留空表示用产品价', 'yoone-subscriptions') . '" /></td>'
. '<td><input type="number" min="0" step="0.1" name="yoone_sub_plans[discount_percent][]" placeholder="' . esc_attr__('如10 表示9折', 'yoone-subscriptions') . '" /></td>'
. '<td><button type="button" class="button yoone-sub-plan-remove">' . esc_html__('删除', 'yoone-subscriptions') . '</button></td>'
. '</tr>';
}
echo '</tbody></table>';
echo '<p><button type="button" class="button button-primary" id="yoone-sub-plan-add">' . esc_html__('新增订阅计划', 'yoone-subscriptions') . '</button></p>';
echo '</div></div>';
}
@ -156,6 +201,33 @@ class Yoone_Subscriptions_Admin {
update_post_meta($post_id, Yoone_Subscriptions::META_TIER_RULES, $tiers);
}
update_post_meta($post_id, Yoone_Subscriptions::META_ALLOW_ONETIME, $onetime ? '1' : '');
// 保存多订阅计划
$plans = array();
if (isset($_POST['yoone_sub_plans']) && is_array($_POST['yoone_sub_plans'])) {
$labels = isset($_POST['yoone_sub_plans']['label']) ? (array) $_POST['yoone_sub_plans']['label'] : array();
$periods = isset($_POST['yoone_sub_plans']['period']) ? (array) $_POST['yoone_sub_plans']['period'] : array();
$prices = isset($_POST['yoone_sub_plans']['price']) ? (array) $_POST['yoone_sub_plans']['price'] : array();
$discounts = isset($_POST['yoone_sub_plans']['discount_percent']) ? (array) $_POST['yoone_sub_plans']['discount_percent'] : array();
$count = max(count($labels), count($periods), count($prices), count($discounts));
for ($i=0; $i<$count; $i++) {
$label = isset($labels[$i]) ? sanitize_text_field($labels[$i]) : '';
$per = isset($periods[$i]) ? sanitize_text_field($periods[$i]) : 'month';
$per = in_array($per, array('month','year'), true) ? $per : 'month';
$price = isset($prices[$i]) ? wc_clean($prices[$i]) : '';
$price = ($price === '' ? 0.0 : floatval(wc_format_decimal($price, 2)));
$disc = isset($discounts[$i]) ? floatval($discounts[$i]) : 0.0;
if ($label === '' && $price <= 0 && $disc <= 0) continue; // 全空行跳过
$plans[] = array(
'id' => uniqid('plan_'),
'label' => $label ? $label : __('订阅计划', 'yoone-subscriptions'),
'period' => $per,
'price' => $price,
'discount_percent' => max(0.0, $disc),
);
}
}
update_post_meta($post_id, Yoone_Subscriptions::META_PLANS, $plans);
}
/**

View File

@ -15,6 +15,8 @@ class Yoone_Subscriptions {
const META_ALLOW_ONETIME = '_yoone_sub_allow_onetime'; // bool
const META_MIN_QTY = '_yoone_sub_min_qty'; // int >=1最小起订量仅订阅模式下生效
const META_TIER_RULES = '_yoone_sub_tier_rules'; // string 逗号分隔“数量:折扣%”对,如 10:5,20:10
// 新增多订阅计划每个计划包含id、label、period、price、discount_percent 可选)
const META_PLANS = '_yoone_sub_plans'; // serialized array
protected static $instance = null;
@ -47,9 +49,26 @@ class Yoone_Subscriptions {
$onetime = (bool) get_post_meta($pid, self::META_ALLOW_ONETIME, true);
$min_qty = absint(get_post_meta($pid, self::META_MIN_QTY, true));
$tiers = get_post_meta($pid, self::META_TIER_RULES, true);
$plans = get_post_meta($pid, self::META_PLANS, true);
$period = in_array($period, array('month','year'), true) ? $period : 'month';
$qty = max(1, $qty);
$price = is_numeric($price) ? floatval($price) : 0.0; // 0 表示按产品原价
// 规范化计划数组
if (! is_array($plans)) {
$plans = array();
} else {
$norm = array();
foreach ($plans as $p) {
$norm[] = array(
'id' => isset($p['id']) ? sanitize_key($p['id']) : uniqid('plan_'),
'label' => isset($p['label']) ? sanitize_text_field($p['label']) : __('订阅计划', 'yoone-subscriptions'),
'period' => (isset($p['period']) && in_array($p['period'], array('month','year'), true)) ? $p['period'] : 'month',
'price' => isset($p['price']) && is_numeric($p['price']) ? floatval($p['price']) : 0.0,
'discount_percent' => isset($p['discount_percent']) && is_numeric($p['discount_percent']) ? floatval($p['discount_percent']) : 0.0,
);
}
$plans = $norm;
}
return array(
'enabled' => $enabled,
'period' => $period,
@ -58,6 +77,7 @@ class Yoone_Subscriptions {
'allow_onetime' => $onetime,
'min_qty' => max(1, $min_qty),
'tier_rules' => is_string($tiers) ? $tiers : '',
'plans' => $plans,
);
}
@ -73,6 +93,7 @@ class Yoone_Subscriptions {
'allow_onetime' => true,
'min_qty' => 1,
'tier_rules' => '',
'plans' => array(),
);
}

View File

@ -118,6 +118,11 @@ class Yoone_Subscriptions_Frontend {
$cfg = Yoone_Subscriptions::get_config($product);
if (! $cfg['enabled']) return $cart_item_data;
// 若是混装子项目存在父容器ID不在子项目上应用订阅避免重复计价
if (isset($cart_item_data['yoone_bundle_parent_id'])) {
return $cart_item_data;
}
$mode = isset($_POST['yoone_sub_purchase_mode']) ? sanitize_text_field($_POST['yoone_sub_purchase_mode']) : '';
$period = isset($_POST['yoone_sub_period']) ? sanitize_text_field($_POST['yoone_sub_period']) : $cfg['period'];
$qty = isset($_POST['yoone_sub_quantity']) ? absint($_POST['yoone_sub_quantity']) : $cfg['qty_default'];
@ -133,7 +138,7 @@ class Yoone_Subscriptions_Frontend {
'mode' => 'subscribe',
'period' => $period,
'qty' => $qty,
'price' => ($cfg['price'] > 0 ? $cfg['price'] : floatval($product->get_price())), // 每周期价格
'price' => ($cfg['price'] > 0 ? $cfg['price'] : floatval($product->get_price())), // 每周期价格;对于混装容器,将在后续根据子项目求和覆盖
);
return $cart_item_data;
}
@ -176,6 +181,37 @@ class Yoone_Subscriptions_Frontend {
$sub_qty = absint($data['qty']);
$cart_qty = absint(isset($item['quantity']) ? $item['quantity'] : 1);
// 若为混装容器,则以其子项目的价格汇总作为每周期基价(若有配置价则优先使用配置价)
if (isset($item['yoone_bundle_container_id'])) {
$container_id = $item['yoone_bundle_container_id'];
// 使用配置价优先,否则求和子项目价
$cfg = Yoone_Subscriptions::get_config($product);
if (!($cfg['price'] > 0)) {
$sum = 0.0;
foreach ($cart->get_cart() as $k2 => $child) {
if (isset($child['yoone_bundle_parent_id']) && $child['yoone_bundle_parent_id'] === $container_id) {
$child_product = isset($child['data']) ? $child['data'] : null;
if ($child_product && is_a($child_product, 'WC_Product')) {
$child_qty = absint(isset($child['quantity']) ? $child['quantity'] : 1);
$sum += floatval($child_product->get_price()) * $child_qty;
}
}
}
$price_per_cycle = $sum; // 以子项目合计为每周期基价
} else {
$price_per_cycle = floatval($cfg['price']);
}
// 将子项目的价格置为 0避免重复计费
foreach ($cart->get_cart() as $k2 => $child) {
if (isset($child['yoone_bundle_parent_id']) && $child['yoone_bundle_parent_id'] === $container_id) {
$child_product = isset($child['data']) ? $child['data'] : null;
if ($child_product && is_a($child_product, 'WC_Product')) {
$child_product->set_price(0);
}
}
}
}
$line_price = $price_per_cycle * $factor * $sub_qty * $cart_qty;
$product->set_price($line_price);
}