From b172bd61bf42c2382886c77a1a50af3604f8196c Mon Sep 17 00:00:00 2001 From: tikkhun Date: Fri, 7 Nov 2025 11:43:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A2=E9=98=85):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E7=BA=A7=E6=8A=98=E6=89=A3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加后台订阅列表管理页面,支持筛选、分页和状态管理 - 引入最小起订量和分级折扣规则配置 - 优化一次性购买逻辑,不再显示订阅信息 - 添加兜底机制确保订阅表存在 - 更新需求文档 --- docs/需求.md | 4 + .../admin/class-yoone-subscriptions-admin.php | 209 ++++++++++++++++++ includes/class-yoone-subscriptions.php | 8 + .../class-yoone-subscriptions-frontend.php | 9 +- yoone-subscriptions.php | 8 + 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 docs/需求.md diff --git a/docs/需求.md b/docs/需求.md new file mode 100644 index 0000000..8dc2808 --- /dev/null +++ b/docs/需求.md @@ -0,0 +1,4 @@ +- 扣款失败需要发邮件给客户 +- 后台有用户订阅列表管理页面 +- 数据存储在数据库 +- 前端用户登陆后可以在 my account 页面中查看已设置的订阅 diff --git a/includes/admin/class-yoone-subscriptions-admin.php b/includes/admin/class-yoone-subscriptions-admin.php index b1d1219..c5317f1 100644 --- a/includes/admin/class-yoone-subscriptions-admin.php +++ b/includes/admin/class-yoone-subscriptions-admin.php @@ -16,6 +16,9 @@ class Yoone_Subscriptions_Admin { add_filter('woocommerce_product_data_tabs', array($this, 'add_tab')); add_action('woocommerce_product_data_panels', array($this, 'render_panel')); add_action('woocommerce_admin_process_product_meta', array($this, 'save_meta')); + + // 在 WooCommerce 菜单下添加“Subscriptions”子菜单 + add_action('admin_menu', array($this, 'register_submenu')); } /** @@ -73,6 +76,17 @@ class Yoone_Subscriptions_Admin { 'description' => __('用于前端默认数量,可在产品页调整。', 'yoone-subscriptions'), )); + // 最小起订量(仅订阅模式下生效) + woocommerce_wp_text_input(array( + 'id' => 'yoone_sub_min_qty', + 'label' => __('最小起订量', 'yoone-subscriptions'), + 'type' => 'number', + 'value' => isset($cfg['min_qty']) ? intval($cfg['min_qty']) : 1, + 'custom_attributes' => array('min' => '1', 'step' => '1'), + 'desc_tip' => true, + 'description' => __('仅在订阅购买模式下生效,购物车与订单将进行数量校验与提示。', 'yoone-subscriptions'), + )); + // 订阅价格(可选,留空则使用产品常规价) woocommerce_wp_text_input(array( 'id' => 'yoone_sub_price', @@ -83,6 +97,17 @@ class Yoone_Subscriptions_Admin { 'description' => __('留空表示使用产品常规价。', 'yoone-subscriptions'), )); + // 分级折扣配置(逗号分隔的“数量:折扣%”对) + woocommerce_wp_text_input(array( + 'id' => 'yoone_sub_tier_rules', + 'label' => __('分级折扣规则', 'yoone-subscriptions'), + 'type' => 'text', + 'placeholder' => '10:5,20:10', + 'value' => isset($cfg['tier_rules']) ? esc_attr($cfg['tier_rules']) : '', + 'desc_tip' => true, + 'description' => __('格式:数量:折扣%,用逗号分隔多级。如“10:5,20:10”表示≥10件打95折,≥20件打9折。留空则不启用。', 'yoone-subscriptions'), + )); + // 是否允许一次性购买 woocommerce_wp_checkbox(array( 'id' => 'yoone_sub_allow_onetime', @@ -107,7 +132,9 @@ class Yoone_Subscriptions_Admin { $enabled = isset($_POST['yoone_sub_enabled']) && 'yes' === $_POST['yoone_sub_enabled']; $period = isset($_POST['yoone_sub_period']) ? sanitize_text_field($_POST['yoone_sub_period']) : 'month'; $qty = isset($_POST['yoone_sub_qty_default']) ? absint($_POST['yoone_sub_qty_default']) : 1; + $min_qty = isset($_POST['yoone_sub_min_qty']) ? absint($_POST['yoone_sub_min_qty']) : 1; $price = isset($_POST['yoone_sub_price']) ? wc_clean($_POST['yoone_sub_price']) : ''; + $tiers = isset($_POST['yoone_sub_tier_rules']) ? sanitize_text_field($_POST['yoone_sub_tier_rules']) : ''; $onetime = isset($_POST['yoone_sub_allow_onetime']) && 'yes' === $_POST['yoone_sub_allow_onetime']; $period = in_array($period, array('month','year'), true) ? $period : 'month'; @@ -117,11 +144,193 @@ class Yoone_Subscriptions_Admin { update_post_meta($post_id, Yoone_Subscriptions::META_ENABLED, $enabled ? '1' : ''); update_post_meta($post_id, Yoone_Subscriptions::META_PERIOD, $period); update_post_meta($post_id, Yoone_Subscriptions::META_QTY_DEFAULT, $qty); + update_post_meta($post_id, Yoone_Subscriptions::META_MIN_QTY, max(1, $min_qty)); if ($price_v === '') { delete_post_meta($post_id, Yoone_Subscriptions::META_PRICE); } else { update_post_meta($post_id, Yoone_Subscriptions::META_PRICE, $price_v); } + if ($tiers === '') { + delete_post_meta($post_id, Yoone_Subscriptions::META_TIER_RULES); + } else { + update_post_meta($post_id, Yoone_Subscriptions::META_TIER_RULES, $tiers); + } update_post_meta($post_id, Yoone_Subscriptions::META_ALLOW_ONETIME, $onetime ? '1' : ''); } + + /** + * 注册 WooCommerce → Subscriptions 子菜单。 + */ + public function register_submenu() { + // 仅限具有管理 WooCommerce 权限的用户可访问 + add_submenu_page( + 'woocommerce', + __('Subscriptions', 'yoone-subscriptions'), + __('Subscriptions', 'yoone-subscriptions'), + 'manage_woocommerce', + 'yoone-subscriptions', + array($this, 'render_subscriptions_page'), + 56 // 显示位置(可选) + ); + } + + /** + * 渲染订阅列表管理页面。 + * 支持按用户、产品、状态筛选与分页。 + */ + public function render_subscriptions_page() { + if (! current_user_can('manage_woocommerce')) { + wp_die(__('您没有权限查看该页面。', 'yoone-subscriptions')); + } + + // 处理状态更新操作(pause/resume/cancel),带 nonce + if (isset($_POST['yoone_sub_action']) && isset($_POST['_wpnonce']) && wp_verify_nonce($_POST['_wpnonce'], 'yoone_subscriptions_action')) { + $action = sanitize_key($_POST['yoone_sub_action']); + $sub_id = isset($_POST['yoone_sub_id']) ? absint($_POST['yoone_sub_id']) : 0; + $allowed = array('pause' => 'paused', 'resume' => 'active', 'cancel' => 'canceled'); + if ($sub_id > 0 && isset($allowed[$action])) { + if (class_exists('Yoone_Subscriptions_DB')) { + Yoone_Subscriptions_DB::update($sub_id, array('status' => $allowed[$action])); + echo '

' . esc_html__('状态已更新。', 'yoone-subscriptions') . '

'; + } + } + } + + // 读取筛选参数 + $status = isset($_GET['status']) ? sanitize_key($_GET['status']) : ''; + $product_id = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0; + $user_query = isset($_GET['user']) ? sanitize_text_field($_GET['user']) : ''; + $paged = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1; + $per_page = 20; + $offset = ($paged - 1) * $per_page; + + global $wpdb; + $table = method_exists('Yoone_Subscriptions_DB', 'table_name') ? Yoone_Subscriptions_DB::table_name() : $wpdb->prefix . 'yoone_subscriptions'; + + // 构造 WHERE 条件 + $where = array('1=1'); + $params = array(); + if (! empty($status)) { $where[] = 'status = %s'; $params[] = $status; } + if ($product_id > 0) { $where[] = 'product_id = %d'; $params[] = $product_id; } + $user_clause = ''; + if (! empty($user_query)) { + // 支持用用户ID或邮箱搜索 + if (is_numeric($user_query)) { + $where[] = 'user_id = %d'; $params[] = absint($user_query); + } else { + // 通过邮箱查询用户ID + $user = get_user_by('email', $user_query); + if ($user) { $where[] = 'user_id = %d'; $params[] = $user->ID; } + else { $where[] = '0=1'; } // 无匹配,返回空 + } + } + + // 查询总数用于分页 + $sql_count = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where); + $total = $wpdb->get_var($wpdb->prepare($sql_count, $params)); + + // 查询当前页数据 + $sql = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where) . " ORDER BY id DESC LIMIT %d OFFSET %d"; + $params_page = array_merge($params, array($per_page, $offset)); + $rows = $wpdb->get_results($wpdb->prepare($sql, $params_page), ARRAY_A); + + // 渲染页面 + echo '
'; + echo '

' . esc_html__('Subscriptions', 'yoone-subscriptions') . '

'; + echo '
'; + + // 筛选表单 + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo ' '; + echo ' '; + echo ' '; + submit_button(__('筛选'), 'secondary', null, false); + echo '
'; + echo '
'; + echo '
'; + + // 列表表格 + echo ''; + echo ''; + $cols = array( + __('ID', 'yoone-subscriptions'), + __('用户', 'yoone-subscriptions'), + __('产品', 'yoone-subscriptions'), + __('周期', 'yoone-subscriptions'), + __('数量', 'yoone-subscriptions'), + __('每周期金额', 'yoone-subscriptions'), + __('状态', 'yoone-subscriptions'), + __('开始时间', 'yoone-subscriptions'), + __('下次续费', 'yoone-subscriptions'), + __('操作', 'yoone-subscriptions'), + ); + foreach ($cols as $c) echo ''; + echo ''; + + if (empty($rows)) { + echo ''; + } else { + foreach ($rows as $r) { + $user = get_user_by('id', intval($r['user_id'])); + $product = wc_get_product(intval($r['product_id'])); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + } + echo '
' . esc_html($c) . '
' . esc_html__('暂无订阅记录。', 'yoone-subscriptions') . '
#' . intval($r['id']) . '' . ($user ? esc_html($user->user_email) . ' (ID ' . intval($user->ID) . ')' : '-') . '' . ($product ? esc_html($product->get_name()) . ' (ID ' . intval($product->get_id()) . ')' : 'ID ' . intval($r['product_id'])) . '' . esc_html($r['period']) . '' . intval($r['qty']) . '' . wc_price(floatval($r['price_per_cycle'])) . '' . esc_html($r['status']) . '' . esc_html($r['start_date']) . '' . esc_html($r['next_renewal_date']) . ''; + echo '
' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . ''; + if ($r['status'] !== 'paused') { + echo ''; + submit_button(__('暂停', 'yoone-subscriptions'), 'small', null, false); + } + echo '
'; + echo '
' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . ''; + if ($r['status'] !== 'active') { + echo ''; + submit_button(__('恢复', 'yoone-subscriptions'), 'small', null, false); + } + echo '
'; + echo '
' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . ''; + if ($r['status'] !== 'canceled') { + echo ''; + submit_button(__('取消', 'yoone-subscriptions'), 'small', null, false); + } + echo '
'; + echo '
'; + + // 分页导航 + if ($total > $per_page) { + $total_pages = ceil($total / $per_page); + echo '
'; + echo paginate_links(array( + 'base' => add_query_arg(array('paged' => '%#%')), + 'format' => '', + 'prev_text' => __('« 上一页', 'yoone-subscriptions'), + 'next_text' => __('下一页 »', 'yoone-subscriptions'), + 'total' => $total_pages, + 'current' => $paged, + )); + echo '
'; + } + + echo '
'; + } } \ No newline at end of file diff --git a/includes/class-yoone-subscriptions.php b/includes/class-yoone-subscriptions.php index 5828061..ce84d49 100644 --- a/includes/class-yoone-subscriptions.php +++ b/includes/class-yoone-subscriptions.php @@ -13,6 +13,8 @@ class Yoone_Subscriptions { const META_QTY_DEFAULT = '_yoone_sub_qty_default'; // int >=1 const META_PRICE = '_yoone_sub_price'; // decimal string 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 protected static $instance = null; @@ -43,6 +45,8 @@ class Yoone_Subscriptions { $qty = absint(get_post_meta($pid, self::META_QTY_DEFAULT, true)); $price = get_post_meta($pid, self::META_PRICE, true); $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); $period = in_array($period, array('month','year'), true) ? $period : 'month'; $qty = max(1, $qty); $price = is_numeric($price) ? floatval($price) : 0.0; // 0 表示按产品原价 @@ -52,6 +56,8 @@ class Yoone_Subscriptions { 'qty_default' => $qty, 'price' => $price, 'allow_onetime' => $onetime, + 'min_qty' => max(1, $min_qty), + 'tier_rules' => is_string($tiers) ? $tiers : '', ); } @@ -65,6 +71,8 @@ class Yoone_Subscriptions { 'qty_default' => 1, 'price' => 0.0, 'allow_onetime' => true, + 'min_qty' => 1, + 'tier_rules' => '', ); } diff --git a/includes/frontend/class-yoone-subscriptions-frontend.php b/includes/frontend/class-yoone-subscriptions-frontend.php index 7a8d487..0fd1e17 100644 --- a/includes/frontend/class-yoone-subscriptions-frontend.php +++ b/includes/frontend/class-yoone-subscriptions-frontend.php @@ -124,11 +124,8 @@ class Yoone_Subscriptions_Frontend { $period = in_array($period, array('month','year'), true) ? $period : $cfg['period']; $qty = max(1, $qty); + // 如果选择一次性购买,则不写入订阅元数据,购物车中不显示任何订阅信息 if ($cfg['allow_onetime'] && $mode === 'onetime') { - // 标记为一次性购买,便于显示 - $cart_item_data['yoone_subscriptions'] = array( - 'mode' => 'onetime', - ); return $cart_item_data; } @@ -147,8 +144,8 @@ class Yoone_Subscriptions_Frontend { public function display_item_data($item_data, $cart_item) { if (empty($cart_item['yoone_subscriptions'])) return $item_data; $data = $cart_item['yoone_subscriptions']; - if ($data['mode'] === 'onetime') { - $item_data[] = array('key' => __('购买方式', 'yoone-subscriptions'), 'value' => __('一次性购买', 'yoone-subscriptions')); + // 仅在选择订阅购买时展示订阅信息;一次性购买不展示任何订阅相关信息 + if (! isset($data['mode']) || $data['mode'] !== 'subscribe') { return $item_data; } $period_label = $data['period'] === 'year' ? __('年', 'yoone-subscriptions') : __('月', 'yoone-subscriptions'); diff --git a/yoone-subscriptions.php b/yoone-subscriptions.php index bb95a2f..0593ed6 100644 --- a/yoone-subscriptions.php +++ b/yoone-subscriptions.php @@ -60,6 +60,14 @@ add_action('plugins_loaded', function() { Yoone_Subscriptions::instance(); Yoone_Subscriptions_Admin::instance(); Yoone_Subscriptions_Frontend::instance(); + + // 兜底:若订阅表不存在则安装(避免早期版本已激活但未创建表的情况) + global $wpdb; + $table = Yoone_Subscriptions_DB::table_name(); + $exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)); + if ($exists !== $table) { + Yoone_Subscriptions_DB::install(); + } }); // 资源