yoone-wc-product-bundles/includes/admin/class-yoone-product-bundles...

421 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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'));
// AJAX: 动态计算有效产品列表(根据选择模式与当前列表,不依赖已保存的配置)
add_action('wp_ajax_yoone_calc_effective_products', array($this, 'ajax_calc_effective_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) {
$tabs['yoone_bundle'] = array(
'label' => __('Product Bundle (Yoone Bundle)', '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);
// 前端使用的“有效产品集合”等从 get_bundle_config 计算;但后台的选择框应显示“原始配置列表”(在 include 模式为包含列表,在 exclude 模式为排除列表,在 all 模式忽略该列表)。
$config = Yoone_Product_Bundles::get_bundle_config($product);
// 读取原始配置列表(不应用 include/exclude/all 的推导),并仅保留 simple 产品ID。
$allowed_raw = get_post_meta($post->ID, Yoone_Product_Bundles::META_ALLOWED_PRODUCTS, true);
$allowed_raw = is_array($allowed_raw) ? array_values(array_map('absint', $allowed_raw)) : array();
if (! empty($allowed_raw)) {
$simple_only = array();
foreach ($allowed_raw as $aid) {
$p = wc_get_product($aid);
if ($p && $p->is_type('simple')) {
$simple_only[] = $aid;
}
}
$allowed_raw = $simple_only;
}
// 供渲染“有效产品总数”的计算结果
$effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array();
$min_qty = $config['min_qty'];
$edit_in_cart = isset($config['edit_in_cart']) ? $config['edit_in_cart'] : 'no';
$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();
$discount_type = isset($config['discount_type']) ? $config['discount_type'] : 'none';
$discount_amount= isset($config['discount_amount']) ? $config['discount_amount'] : 0;
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>';
// 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 selector: always render the field and select element,
// but hide it initially when in 'all' mode so that toggling from ALL -> include/exclude works without a full reload.
$is_all = ($select_mode === 'all');
$list_label = ($select_mode === 'exclude')
? esc_html__('Excluded Products', 'yoone-product-bundles')
: esc_html__('Included Products', 'yoone-product-bundles');
echo '<p class="form-field" id="yoone_bundle_products_list_field"' . ($is_all ? ' style="display:none"' : '') . '>';
echo '<label>' . $list_label . '</label>';
echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
echo ' ';
echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>';
// WooCommerce product search select. We keep the select always in DOM for JS to toggle.
echo '<select class="wc-product-search" multiple style="width: 100%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
// 显示“原始配置列表”作为可编辑项exclude 模式记为排除列表include 模式记为包含列表)
if (! empty($allowed_raw)) {
foreach ($allowed_raw 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>';
// Dynamic description based on selection mode
if ($select_mode === 'include') {
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . '</span>';
} else { // exclude or all (label will be updated via JS when switching)
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . '</span>';
}
echo '</p>';
// 只读展示:有效产品列表与总数(基于当前配置推导)
echo '<div id="yoone_effective_products_block" class="yoone-effective-products" style="margin-top:10px;">';
echo '<div class="yoone-effective-actions" style="margin-bottom:6px;">';
echo '<button type="button" class="button yoone-refresh-effective-products">' . esc_html__('Refresh Effective Products', 'yoone-product-bundles') . '</button>';
echo '</div>';
echo '<p>' . sprintf(esc_html__('Effective products (%s):', 'yoone-product-bundles'), '<span class="yoone-effective-count">' . (int) count($effective_allowed) . '</span>') . '</p>';
echo '<div id="yoone_effective_products_list">';
if (! empty($effective_allowed)) {
echo '<ul class="yoone-effective-list" style="max-height:220px; overflow:auto; margin:0; padding-left:1.5em; border:1px solid #ddd; background:#fff;">';
foreach ($effective_allowed as $pid) {
$p = wc_get_product($pid);
if ($p) {
printf('<li>%s <span style="color:#999;">(#%d)</span></li>', esc_html($p->get_formatted_name()), (int) $pid);
}
}
echo '</ul>';
} else {
echo '<p class="description">' . esc_html__('No products will be available with the current configuration.', 'yoone-product-bundles') . '</p>';
}
echo '</div>';
echo '</div>';
// 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'),
));
// Enable editing bundle in cart
woocommerce_wp_checkbox(array(
'id' => 'yoone_bundle_edit_in_cart',
'label' => __('Enable "Edit in Cart"', 'yoone-product-bundles'),
'desc_tip' => true,
'description' => __('Allow customers to change bundle child item quantities (and optionally remove child items) on the Cart page.', 'yoone-product-bundles'),
'value' => $edit_in_cart === 'yes' ? 'yes' : 'no',
));
// Bundle discount type
woocommerce_wp_select(array(
'id' => 'yoone_bundle_discount_type',
'label' => __('Bundle Discount Type', 'yoone-product-bundles'),
'options' => array(
'none' => __('None', 'yoone-product-bundles'),
'percent' => __('Percentage (%)', 'yoone-product-bundles'),
'fixed' => __('Fixed Amount', 'yoone-product-bundles'),
),
'value' => $discount_type,
'desc_tip' => true,
'description' => __('Apply a discount to the total price of selected bundle items.', 'yoone-product-bundles'),
));
// Bundle discount amount
woocommerce_wp_text_input(array(
'id' => 'yoone_bundle_discount_amount',
'label' => __('Bundle Discount Amount', 'yoone-product-bundles'),
'type' => 'number',
'value' => $discount_amount,
'custom_attributes' => array('step' => '0.01', 'min' => '0'),
'desc_tip' => true,
'description' => __('If type is Percentage, enter a number like 10 for 10%. If Fixed, enter the currency amount to subtract from the bundle total.', 'yoone-product-bundles'),
));
// 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 '<p class="form-field"><label>' . esc_html__('Display Terms', 'yoone-product-bundles') . '</label>';
echo '<select id="yoone_bundle_group_terms" class="wc-enhanced-select" multiple style="width: 100%;" name="yoone_bundle_group_terms[]" data-placeholder="' . esc_attr__('Select terms to group products by…', 'yoone-product-bundles') . '">';
$terms = get_terms(array('taxonomy' => $group_taxonomy, 'hide_empty' => false));
foreach ($terms as $t) {
$selected = in_array($t->term_id, $group_terms, 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 the selected taxonomy terms on the frontend. Only products matching the selected terms 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_group_taxonomy']) || isset($_POST['yoone_bundle_group_terms']) || isset($_POST['yoone_bundle_select_mode']) || isset($_POST['yoone_bundle_edit_in_cart']) || isset($_POST['yoone_bundle_discount_type']) || isset($_POST['yoone_bundle_discount_amount']);
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));
// 保存“编辑购物车”开关
$edit_in_cart = isset($_POST['yoone_bundle_edit_in_cart']) ? 'yes' : 'no';
update_post_meta($post_id, Yoone_Product_Bundles::META_EDIT_IN_CART, $edit_in_cart);
// 保存折扣
$discount_type = isset($_POST['yoone_bundle_discount_type']) ? sanitize_text_field(wp_unslash($_POST['yoone_bundle_discount_type'])) : 'none';
if (!in_array($discount_type, array('none','percent','fixed'), true)) {
$discount_type = 'none';
}
$discount_amount = isset($_POST['yoone_bundle_discount_amount']) ? floatval($_POST['yoone_bundle_discount_amount']) : 0;
if ($discount_amount < 0) $discount_amount = 0.0;
update_post_meta($post_id, Yoone_Product_Bundles::META_DISCOUNT_TYPE, $discount_type);
update_post_meta($post_id, Yoone_Product_Bundles::META_DISCOUNT_AMOUNT, $discount_amount);
// 保存选择模式include | exclude | all
$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);
// 保存 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);
}
/**
* 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');
// 返回所有已发布的 simple 产品包含任何目录可见性visible/hidden/exclude-from-catalog/search
$product_ids = wc_get_products(array(
'type' => array('simple'),
'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1,
'orderby' => 'title',
'order' => 'ASC',
'return' => 'ids',
));
$results = array();
if (is_array($product_ids)) {
foreach ($product_ids as $pid) {
$product = wc_get_product($pid);
if ($product) {
$results[] = array(
'id' => (int) $pid,
'text' => $product->get_formatted_name(),
);
}
}
}
wp_send_json_success($results);
}
/**
* AJAX: 动态计算有效产品列表与总数
* 输入select_mode, allowed_products[](原始配置列表,不应用推导)
* 输出:{ items: [{id,text}], count: number }
*/
public function ajax_calc_effective_products() {
if (! current_user_can('edit_products')) {
wp_send_json_error('permission_denied', 403);
}
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
$select_mode = isset($_POST['select_mode']) ? sanitize_text_field($_POST['select_mode']) : 'include';
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
$allowed_raw = isset($_POST['allowed_products']) ? (array) $_POST['allowed_products'] : array();
$allowed_raw = array_values(array_filter(array_map('absint', $allowed_raw)));
// 仅保留 simple 产品ID
if (! empty($allowed_raw)) {
$simple_only = array();
foreach ($allowed_raw as $aid) {
$p = wc_get_product($aid);
if ($p && $p->is_type('simple')) {
$simple_only[] = $aid;
}
}
$allowed_raw = $simple_only;
}
$effective_ids = array();
if ($select_mode === 'include') {
$effective_ids = $allowed_raw;
} else {
// exclude/all: 先取全部 simple 产品,再按模式处理
$all_ids = wc_get_products(array(
'type' => array('simple'),
'status' => array('publish'),
'catalog_visibility' => 'any',
'limit' => -1,
'return' => 'ids',
));
$all_ids = is_array($all_ids) ? array_values(array_map('absint', $all_ids)) : array();
if ($select_mode === 'all') {
$effective_ids = $all_ids;
} else { // exclude
$effective_ids = array_values(array_diff($all_ids, $allowed_raw));
}
}
$items = array();
foreach ($effective_ids as $pid) {
$p = wc_get_product($pid);
if ($p) {
$items[] = array(
'id' => (int) $pid,
'text' => $p->get_formatted_name(),
);
}
}
wp_send_json_success(array(
'items' => $items,
'count' => count($items),
));
}
/**
* 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);
}
}