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 '';
+ $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 '| ' . esc_html($c) . ' | ';
+ echo '
';
+
+ if (empty($rows)) {
+ echo '| ' . esc_html__('暂无订阅记录。', 'yoone-subscriptions') . ' |
';
+ } else {
+ foreach ($rows as $r) {
+ $user = get_user_by('id', intval($r['user_id']));
+ $product = wc_get_product(intval($r['product_id']));
+ echo '';
+ echo '| #' . intval($r['id']) . ' | ';
+ echo '' . ($user ? esc_html($user->user_email) . ' (ID ' . intval($user->ID) . ')' : '-') . ' | ';
+ echo '' . ($product ? esc_html($product->get_name()) . ' (ID ' . intval($product->get_id()) . ')' : 'ID ' . intval($r['product_id'])) . ' | ';
+ echo '' . esc_html($r['period']) . ' | ';
+ echo '' . intval($r['qty']) . ' | ';
+ echo '' . wc_price(floatval($r['price_per_cycle'])) . ' | ';
+ echo '' . esc_html($r['status']) . ' | ';
+ echo '' . esc_html($r['start_date']) . ' | ';
+ echo '' . esc_html($r['next_renewal_date']) . ' | ';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo ' | ';
+ 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();
+ }
});
// 资源