feat(admin): 新增订阅计划面板式管理并自动启用订阅

refactor(frontend): 添加我的账户订阅列表端点与渲染
docs: 更新各文档文件说明与需求清单
style: 优化后台订阅计划面板样式与交互
This commit is contained in:
tikkhun 2025-11-07 18:00:44 +08:00
parent c2067450cb
commit 7ebf556641
13 changed files with 331 additions and 34 deletions

View File

@ -2,9 +2,12 @@
为 WooCommerce 提供基础订阅能力:在单个产品层面配置订阅计划(周期、数量、订阅价、一次性购买选项),在前端展示订阅选项,并在购物车/订单中显示订阅摘要与按订阅规则计算价格。 为 WooCommerce 提供基础订阅能力:在单个产品层面配置订阅计划(周期、数量、订阅价、一次性购买选项),在前端展示订阅选项,并在购物车/订单中显示订阅摘要与按订阅规则计算价格。
本版本新增后台“面板式订阅计划”管理(可折叠面板),支持配置多个订阅计划并保存为产品元数据;保存时如存在至少一个计划会自动启用订阅。前端当前仍基于“基础字段”(周期/数量/价格)进行渲染与计价,订阅计划的前端选择将在后续版本提供。
## 功能概述 ## 功能概述
- 产品级订阅计划:周期(月/年)、默认订阅数量、订阅价格(可选)、是否允许一次性购买; - 产品级订阅计划(基础字段):周期(月/年)、默认订阅数量、订阅价格(可选)、是否允许一次性购买;
- 后台支持“多个订阅计划”的配置(名称、周期、每周期价格或折扣百分比);保存时若存在计划将自动启用订阅;
- 前端产品页展示订阅选项,显示每周期价格与相对常规价的折扣百分比; - 前端产品页展示订阅选项,显示每周期价格与相对常规价的折扣百分比;
- 加入购物车时记录订阅参数; - 加入购物车时记录订阅参数;
- 购物车行项目价格 = 每周期价格 × 周期系数(年=12× 订阅数量 × 购物车数量; - 购物车行项目价格 = 每周期价格 × 周期系数(年=12× 订阅数量 × 购物车数量;
@ -20,11 +23,14 @@
1. 后台 → 产品 → 编辑某个产品; 1. 后台 → 产品 → 编辑某个产品;
2. 在“订阅计划”标签页: 2. 在“订阅计划”标签页:
- 启用订阅; - 启用订阅(保存时如存在订阅计划将自动开启,即使未勾选也会开启);
- 选择订阅周期(月/年); - 基础字段(仍生效,当前前端基于这些字段渲染与计价):
- 设置默认订阅数量(前端默认值,可修改); - 订阅周期(月/年)、默认订阅数量、订阅价格(每周期,留空表示使用产品常规价)、分级折扣规则(数量:折扣%
- 设置订阅价格(每周期、可选,留空则使用产品常规价); - 是否允许一次性购买(允许时前端可切换一次性/订阅购买)。
- 是否允许一次性购买(允许时前端可切换一次性购买或订阅购买)。 - 订阅计划面板(新增):使用“新增订阅计划”按钮添加计划,面板内包含:
- 计划名称、周期(月/年、每周期价格留空使用产品价、折扣百分比可选10 表示约 9 折)。
- 支持折叠/展开、删除计划。保存后计划以数组形式写入产品元数据。
- 注:当前前端尚未提供“在产品页选择某个计划”的 UI因此建议仍按需设置基础字段以保证前端行为订阅计划的选择与联动价格将在后续版本实现。
## 前端效果 ## 前端效果
@ -33,17 +39,24 @@
- 显示每周期订阅价与相对常规价的折扣提示; - 显示每周期订阅价与相对常规价的折扣提示;
- 加入购物车后行项目价格按规则动态计算,购物车显示订阅摘要。 - 加入购物车后行项目价格按规则动态计算,购物车显示订阅摘要。
## 技术实现 ## 技术实现与设计
- 主入口:`yoone-subscriptions.php`,注册资源、加载国际化、依赖检查、加载模块; - 主入口:`yoone-subscriptions.php`,注册资源、加载国际化、依赖检查、加载模块;
- 核心:`includes/class-yoone-subscriptions.php` 定义 postmeta 键名,提供 `get_config()` 与周期系数方法; - 核心:`includes/class-yoone-subscriptions.php` 定义 postmeta 键名,提供 `get_config()` 与周期系数方法;
- 后台:`includes/admin/class-yoone-subscriptions-admin.php` 在产品编辑页添加订阅面板,保存 postmeta含 nonce 与 sanitize - 后台:`includes/admin/class-yoone-subscriptions-admin.php`
- 在产品编辑页添加订阅面板simple/variable/yoone_bundle
- 渲染基础字段与“订阅计划列表”(以表格输出,`assets/js/admin.js` 动态转换为可折叠面板);
- 保存 postmeta含 nonce 校验与 sanitize当存在至少一个有效订阅计划时自动设置“启用订阅”
- 前端:`includes/frontend/class-yoone-subscriptions-frontend.php` - 前端:`includes/frontend/class-yoone-subscriptions-frontend.php`
- `woocommerce_before_add_to_cart_button` 渲染订阅选项; - `woocommerce_before_add_to_cart_button` 渲染订阅选项;
- `woocommerce_add_to_cart_validation` 校验参数; - `woocommerce_add_to_cart_validation` 校验参数;
- `woocommerce_add_cart_item_data` 存储订阅数据到购物车项; - `woocommerce_add_cart_item_data` 存储订阅数据到购物车项;
- `woocommerce_get_item_data` 展示订阅摘要; - `woocommerce_get_item_data` 展示订阅摘要;
- `woocommerce_before_calculate_totals` 动态设置行项目价格。 - `woocommerce_before_calculate_totals` 动态设置行项目价格。
- My Account注册 `subscriptions` 端点与菜单项“我的订阅”,显示当前用户的订阅实例列表(数据来源于 `includes/models/class-yoone-subscriptions-db.php`)。
- 资源:
- 后台:`assets/css/admin.css`(面板式样式)、`assets/js/admin.js`(隐藏旧字段、计划的新增/删除与折叠交互)
- 前端:`assets/css/frontend.css`(简版样式)、`assets/js/frontend.js`(一次性/订阅模式的样式钩子)
## 兼容性与支付 ## 兼容性与支付
@ -54,6 +67,7 @@
- 后台产品编辑页 → 订阅计划面板(示例) - 后台产品编辑页 → 订阅计划面板(示例)
- 前端产品页 → 订阅选项区块(示例) - 前端产品页 → 订阅选项区块(示例)
- My Account → 我的订阅(示例)
(请在实际部署后补充截图文件并更新此章节) (请在实际部署后补充截图文件并更新此章节)
@ -67,6 +81,10 @@
- 总价以两者乘积计算。若不希望用户在订阅场景改变购物车数量,可考虑在前端或后台强制 sold individually可后续扩展 - 总价以两者乘积计算。若不希望用户在订阅场景改变购物车数量,可考虑在前端或后台强制 sold individually可后续扩展
4. 是否支持变体产品? 4. 是否支持变体产品?
- 面板在 simple/variable 产品上显示;变体粒度订阅可后续扩展至变体级别元数据。 - 面板在 simple/variable 产品上显示;变体粒度订阅可后续扩展至变体级别元数据。
5. 后台新增的“多个订阅计划”前端是否可选?
- 当前版本尚未提供选择 UI计划数据已保存并可用于后续扩展当前前端渲染与计价仍基于基础字段。
6. 为什么 My Account 没有出现“我的订阅”?
- 首次启用插件后,访问 设置 → 固定链接 → 保存 以刷新路由规则;或停用再启用插件。然后在“我的账户”页面即可看到“我的订阅”菜单。
## 性能与测试 ## 性能与测试

View File

@ -1,3 +1,66 @@
/* Yoone Subscriptions 后台样式(简版) */ /*
#yoone_subscriptions_data .description { color: #666; display: block; margin-top: 6px; } * Yoone Subscriptions 后台样式
#yoone_subscriptions_data .form-field { margin-bottom: 12px; } * - 兼容原有表格式订阅计划 JS 转换为面板表格默认隐藏但保留样式
* - 新增面板式订阅计划的外观标题操作按钮折叠/删除详情区块
* - 风格尽量贴近 WooCommerce 后台组件保持一致的交互与视觉
*/
#yoone_subscriptions_data .description { color: #666; display: block; margin-top: 6px; line-height: 1.5; }
#yoone_subscriptions_data .form-field { margin-bottom: 12px; }
/* 订阅计划表格布局优化(兼容保留但默认隐藏) */
#yoone_subscriptions_data .yoone-sub-plans-table { margin-top: 10px; width: 100%; table-layout: fixed; }
#yoone_subscriptions_data .yoone-sub-plans-table thead th { font-weight: 600; }
#yoone_subscriptions_data .yoone-sub-plans-table th,
#yoone_subscriptions_data .yoone-sub-plans-table td { padding: 8px 10px; vertical-align: middle; }
#yoone_subscriptions_data .yoone-sub-plans-table tr:nth-child(even) { background-color: #f9f9f9; }
/* 列宽(与 table-layout: fixed 配合) */
#yoone_subscriptions_data .yoone-sub-plans-table colgroup col:nth-child(1) { width: 28%; }
#yoone_subscriptions_data .yoone-sub-plans-table colgroup col:nth-child(2) { width: 16%; }
#yoone_subscriptions_data .yoone-sub-plans-table colgroup col:nth-child(3) { width: 22%; }
#yoone_subscriptions_data .yoone-sub-plans-table colgroup col:nth-child(4) { width: 18%; }
#yoone_subscriptions_data .yoone-sub-plans-table colgroup col:nth-child(5) { width: 16%; }
/* 输入控件统一风格(参考 Woo 后台表单) */
#yoone_subscriptions_data .yoone-sub-plans-table input[type="text"],
#yoone_subscriptions_data .yoone-sub-plans-table input[type="number"],
#yoone_subscriptions_data .yoone-sub-plans-table select {
width: 100%;
box-sizing: border-box;
min-height: 32px;
}
/* 行内按钮精简样式 */
#yoone_subscriptions_data .yoone-sub-plans-table .button {
min-height: 28px;
line-height: 26px;
}
/* 新增计划按钮间距 */
#yoone_subscriptions_data #yoone-sub-plan-add { margin-top: 8px; }
/* 面板式订阅计划样式 */
#yoone_subscriptions_data #yoone-sub-plans-panels { margin-top: 12px; }
#yoone_subscriptions_data .yoone-sub-plan-panel {
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
margin-bottom: 12px;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
#yoone_subscriptions_data .yoone-sub-plan-header {
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f6f7f7;
border-bottom: 1px solid #e2e4e7;
}
#yoone_subscriptions_data .yoone-sub-plan-title {
width: 60%;
max-width: 420px;
}
#yoone_subscriptions_data .yoone-sub-plan-actions .button { margin-left: 8px; }
#yoone_subscriptions_data .yoone-sub-plan-body { padding: 12px; }
#yoone_subscriptions_data .yoone-sub-plan-body p { margin: 0 0 10px; }
#yoone_subscriptions_data .yoone-sub-plan-body.collapsed { display: none; }

View File

@ -1,4 +1,8 @@
/* Yoone Subscriptions 前端样式(简版) */ /*
* Yoone Subscriptions 前端样式简版
* - 产品页订阅选项容器.yoone-subs-block与基础排版
* - 样式钩子.yoone-subs-onetime 用于在选择一次性购买时做轻量视觉变化
*/
.yoone-subs-block { border: 1px solid #e5e5e5; padding: 12px; margin: 12px 0; border-radius: 6px; } .yoone-subs-block { border: 1px solid #e5e5e5; padding: 12px; margin: 12px 0; border-radius: 6px; }
.yoone-subs-block h3 { margin: 0 0 8px; } .yoone-subs-block h3 { margin: 0 0 8px; }
.yoone-subs-row { margin: 8px 0; display: flex; gap: 8px; align-items: center; } .yoone-subs-row { margin: 8px 0; display: flex; gap: 8px; align-items: center; }

View File

@ -1,29 +1,93 @@
/**
* 后台 JS订阅计划面板交互
* - 隐藏旧的单一订阅字段周期默认数量最小起订量每周期价格分级折扣
* - 将后端渲染的表格保持字段 name 不变转换为可折叠面板形式
* - 支持新增/删除计划折叠/展开计划详情
* - 保持与后端 save_meta 的兼容使用相同的 name 数组结构提交
*/
(function($){ (function($){
$(function(){ $(function(){
// 隐藏旧的单一订阅字段
['yoone_sub_enabled','yoone_sub_period','yoone_sub_qty_default','yoone_sub_min_qty','yoone_sub_price','yoone_sub_tier_rules'].forEach(function(id){
var $input = $('#'+id);
if ($input.length) {
var $field = $input.closest('.form-field');
if ($field.length) { $field.hide(); }
}
});
var $table = $('.yoone-sub-plans-table');
var $tbody = $('#yoone-sub-plans-body'); var $tbody = $('#yoone-sub-plans-body');
var $addBtn = $('#yoone-sub-plan-add'); var $addBtn = $('#yoone-sub-plan-add');
function buildPanel(labelInput, periodSelect, priceInput, discountInput){
var $panel = $('<div class="yoone-sub-plan-panel"/>');
var $header = $('<div class="yoone-sub-plan-header"/>');
var $actions = $('<div class="yoone-sub-plan-actions"/>');
var $toggle = $('<button type="button" class="button yoone-sub-plan-toggle" aria-expanded="true"/>').text((typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.collapse : '折叠'));
var $remove = $('<button type="button" class="button yoone-sub-plan-remove"/>').text((typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.remove : '删除'));
var $title = $(labelInput || '<input type="text" class="yoone-sub-plan-title" name="yoone_sub_plans[label][]" placeholder="'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderLabel : '例如:标准计划')+'" />');
$title.addClass('yoone-sub-plan-title');
$actions.append($toggle).append(' ').append($remove);
$header.append($title).append($actions);
var $body = $('<div class="yoone-sub-plan-body"/>');
var $periodRow = $('<p/>').append('<label>'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.period : '周期')+'</label> ');
var $period = $(periodSelect || [
'<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>'
].join(''));
$periodRow.append($period);
var $priceRow = $('<p/>').append('<label>'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.pricePerPeriod : '每周期价格')+'</label> ');
var $price = $(priceInput || '<input type="text" name="yoone_sub_plans[price][]" placeholder="'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderPrice : '留空表示用产品价')+'" />');
$priceRow.append($price);
var $discRow = $('<p/>').append('<label>'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.discountPercent : '折扣百分比(可选)')+'</label> ');
var $disc = $(discountInput || '<input type="number" min="0" step="0.1" name="yoone_sub_plans[discount_percent][]" placeholder="'+(typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.placeholderDiscount : '如10 表示9折')+'" />');
$discRow.append($disc);
$body.append($periodRow).append($priceRow).append($discRow);
$panel.append($header).append($body);
return $panel;
}
if ($tbody.length && $addBtn.length) { if ($tbody.length && $addBtn.length) {
// 将现有表格行转换为面板
var $container = $('<div id="yoone-sub-plans-panels"/>');
$table.before($container);
$tbody.find('tr').each(function(){
var $tr = $(this);
var $label = $tr.find('input[name="yoone_sub_plans[label][]"]').detach();
var $period = $tr.find('select[name="yoone_sub_plans[period][]"]').detach();
var $price = $tr.find('input[name="yoone_sub_plans[price][]"]').detach();
var $disc = $tr.find('input[name="yoone_sub_plans[discount_percent][]"]').detach();
var $panel = buildPanel($label, $period, $price, $disc);
$container.append($panel);
});
// 隐藏表格
$table.hide();
// 新增面板
$addBtn.on('click', function(){ $addBtn.on('click', function(){
var row = [ $container.append(buildPanel());
'<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(); $(document).on('click', '.yoone-sub-plan-remove', function(){
$(this).closest('.yoone-sub-plan-panel').remove();
});
// 折叠/展开
$(document).on('click', '.yoone-sub-plan-toggle', function(){
var $btn = $(this);
var $body = $btn.closest('.yoone-sub-plan-panel').find('.yoone-sub-plan-body');
var expanded = $btn.attr('aria-expanded') === 'true';
$btn.attr('aria-expanded', expanded ? 'false' : 'true');
$btn.text(expanded ? (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.expand : '展开') : (typeof yooneSubsI18n !== 'undefined' ? yooneSubsI18n.collapse : '折叠'));
$body.toggleClass('collapsed', expanded);
}); });
} }
}); });

View File

@ -1,3 +1,7 @@
/**
* 前端 JS订阅选项的轻量交互
* - 当用户切换一次性购买/订阅购买为容器添加/移除样式钩子便于样式控制
*/
(function($){ (function($){
// 切换一次性购买/订阅购买时,简单控制 UI可按需扩展隐藏/显示订阅字段)。 // 切换一次性购买/订阅购买时,简单控制 UI可按需扩展隐藏/显示订阅字段)。
$(document).on('change', 'input[name="yoone_sub_purchase_mode"]', function(){ $(document).on('change', 'input[name="yoone_sub_purchase_mode"]', function(){

View File

@ -1 +1,2 @@
# 实现订阅功能 <!-- 文件说明:技术文档,记录插件的实现细节、模块职责与开发约定 -->
# 实现订阅功能

View File

@ -1,3 +1,4 @@
<!-- 文件说明:数据库模型文档,描述订阅实例表结构与 CRUD 接口设计 -->
# 数据库模型Yoone Subscriptions # 数据库模型Yoone Subscriptions
本插件新增一个独立的数据库模型目录 `includes/models/`,用于管理用户订阅实例的持久化数据。当前实现增加了基础表结构与 CRUD 接口,方便未来接入续订、暂停、取消等业务。 本插件新增一个独立的数据库模型目录 `includes/models/`,用于管理用户订阅实例的持久化数据。当前实现增加了基础表结构与 CRUD 接口,方便未来接入续订、暂停、取消等业务。

View File

@ -1,4 +1,10 @@
<!-- 文件说明:需求清单,记录业务侧的功能点与用户期望 -->
- 扣款失败需要发邮件给客户 - 扣款失败需要发邮件给客户
- 后台有用户订阅列表管理页面 - 后台有用户订阅列表管理页面
- 数据存储在数据库 - 数据存储在数据库
- 前端用户登陆后可以在 my account 页面中查看自己已设置的订阅 - 前端用户登陆后可以在 my account 页面中查看自己已设置的订阅
- 配置订阅计划的产品显示订阅计划的选项 可以加入购物车
- 支付手段可以使用 woocommerce 的payments 进行订阅配置
- 订阅生成的订单应该包括订阅计划的信息
- 订阅在第一次之后,定时自动续费并生成正确的订单
-

View File

@ -1,3 +1,4 @@
<!-- 文件说明:项目新增需求文档,列出插件需要实现的功能与规范 -->
开发一个名为`yoone-subscriptions`的WordPress插件实现订阅功能。 开发一个名为`yoone-subscriptions`的WordPress插件实现订阅功能。
1. 插件基础框架: 1. 插件基础框架:
@ -14,6 +15,7 @@
- 产品页 - 产品页
- 设置了订阅计划的产品页显示订阅计划的选项(以及金额折扣) - 设置了订阅计划的产品页显示订阅计划的选项(以及金额折扣)
- 可以加购 - 可以加购
- 帮我生成订阅
- 购物车 - 购物车
- 可以在购物车中添加订阅项目(显示订阅标识) - 可以在购物车中添加订阅项目(显示订阅标识)
- 购物车中订阅产品的行项目价格 = 订阅价格 × 订阅周期 × 订阅数量 - 购物车中订阅产品的行项目价格 = 订阅价格 × 订阅周期 × 订阅数量

View File

@ -1,6 +1,13 @@
<?php <?php
/** /**
* 后台:产品编辑页订阅计划配置面板。 * 后台:产品编辑页订阅计划配置面板
*
* 主要职责:
* - WooCommerce 产品数据区添加“订阅计划”标签页simple/variable/yoone_bundle
* - 渲染基础字段:启用、周期、默认订阅量、最小起订量、每周期价格、分级折扣、允许一次性购买
* - 渲染“订阅计划列表”表格(保持字段 name 结构),并由 admin.js 在前端转换为可折叠面板
* - 保存元数据(含 nonce 校验与 sanitize当存在至少一个有效订阅计划时自动启用订阅
* - WooCommerce 菜单下注册 Subscriptions 子菜单并渲染订阅列表页(简版)
*/ */
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@ -125,7 +132,8 @@ class Yoone_Subscriptions_Admin {
echo '<p class="description">' . esc_html__('可以为该产品配置多个订阅计划,前端用户可在购买时选择其一。每个计划可设置周期与每周期价格,或以折扣百分比表示相对产品价的优惠。', 'yoone-subscriptions') . '</p>'; echo '<p class="description">' . esc_html__('可以为该产品配置多个订阅计划,前端用户可在购买时选择其一。每个计划可设置周期与每周期价格,或以折扣百分比表示相对产品价的优惠。', 'yoone-subscriptions') . '</p>';
$plans = isset($cfg['plans']) && is_array($cfg['plans']) ? $cfg['plans'] : array(); $plans = isset($cfg['plans']) && is_array($cfg['plans']) ? $cfg['plans'] : array();
echo '<table class="widefat fixed" style="margin-top:10px;">'; echo '<table class="wp-list-table widefat fixed striped yoone-sub-plans-table" style="margin-top:10px; table-layout:fixed;">';
echo '<colgroup><col/><col/><col/><col/><col/></colgroup>';
echo '<thead><tr>' 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>'
@ -186,7 +194,7 @@ class Yoone_Subscriptions_Admin {
$qty = max(1, $qty); $qty = max(1, $qty);
$price_v = ($price === '' ? '' : wc_format_decimal($price, 2)); $price_v = ($price === '' ? '' : wc_format_decimal($price, 2));
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_PERIOD, $period);
update_post_meta($post_id, Yoone_Subscriptions::META_QTY_DEFAULT, $qty); 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)); update_post_meta($post_id, Yoone_Subscriptions::META_MIN_QTY, max(1, $min_qty));
@ -228,6 +236,10 @@ class Yoone_Subscriptions_Admin {
} }
} }
update_post_meta($post_id, Yoone_Subscriptions::META_PLANS, $plans); update_post_meta($post_id, Yoone_Subscriptions::META_PLANS, $plans);
// 如果存在至少一个有效的订阅计划,则自动启用订阅
$auto_enabled = ! empty($plans);
update_post_meta($post_id, Yoone_Subscriptions::META_ENABLED, ($enabled || $auto_enabled) ? '1' : '');
} }
/** /**

View File

@ -13,6 +13,14 @@ class Yoone_Subscriptions_Frontend {
} }
private function __construct() { private function __construct() {
// 注册“我的账户 → 订阅”端点与菜单
add_action('init', array($this, 'register_account_endpoint'));
add_filter('query_vars', array($this, 'add_query_var'));
add_filter('woocommerce_get_query_vars', array($this, 'register_wc_query_vars'));
add_filter('woocommerce_endpoint_subscriptions_title', array($this, 'endpoint_title'));
add_filter('woocommerce_account_menu_items', array($this, 'add_account_menu_item'));
add_action('woocommerce_account_subscriptions_endpoint', array($this, 'render_account_subscriptions'));
// 在简单产品的 add-to-cart 区域前渲染订阅选项 // 在简单产品的 add-to-cart 区域前渲染订阅选项
add_action('woocommerce_before_add_to_cart_button', array($this, 'render_subscription_options')); add_action('woocommerce_before_add_to_cart_button', array($this, 'render_subscription_options'));
@ -27,6 +35,108 @@ class Yoone_Subscriptions_Frontend {
add_action('woocommerce_before_calculate_totals', array($this, 'adjust_subscription_price'), 20, 1); add_action('woocommerce_before_calculate_totals', array($this, 'adjust_subscription_price'), 20, 1);
} }
/**
* 注册 My Account 的“subscriptions”端点。
* - 访问地址示例:/my-account/subscriptions/
*/
public function register_account_endpoint() {
// 为 WP 添加 rewrite 端点(仅需注册一次;在插件激活时会 flush rules
add_rewrite_endpoint('subscriptions', EP_ROOT | EP_PAGES);
}
/**
* 将自定义端点加入 WP query vars避免部分站点未识别导致 404
*/
public function add_query_var($vars) {
$vars[] = 'subscriptions';
return $vars;
}
/**
* 将端点注册到 WooCommerce endpoint 映射中(用于标题与模板路由)。
*/
public function register_wc_query_vars($vars) {
$vars['subscriptions'] = 'subscriptions';
return $vars;
}
/**
* 设置端点页面标题。
*/
public function endpoint_title($title) {
return __('我的订阅', 'yoone-subscriptions');
}
/**
* My Account 菜单中新增“我的订阅”。
*/
public function add_account_menu_item($items) {
// 在“订单”后插入“订阅”菜单
$new = array();
foreach ($items as $key => $label) {
$new[$key] = $label;
if ('orders' === $key) {
$new['subscriptions'] = __('我的订阅', 'yoone-subscriptions');
}
}
// 如果没有“订单”菜单,则直接追加
if (! isset($new['subscriptions'])) {
$new['subscriptions'] = __('我的订阅', 'yoone-subscriptions');
}
return $new;
}
/**
* 渲染 My Account 订阅 列表。
*/
public function render_account_subscriptions() {
if (! is_user_logged_in()) {
echo '<p>' . esc_html__('请先登录以查看您的订阅。', 'yoone-subscriptions') . '</p>';
return;
}
$uid = get_current_user_id();
if (! class_exists('Yoone_Subscriptions_DB')) {
echo '<p>' . esc_html__('订阅数据模块未加载。', 'yoone-subscriptions') . '</p>';
return;
}
$subs = Yoone_Subscriptions_DB::get_by_user($uid, array('limit' => 50, 'offset' => 0));
echo '<h2>' . esc_html__('我的订阅', 'yoone-subscriptions') . '</h2>';
if (empty($subs)) {
echo '<p>' . esc_html__('您目前还没有订阅。', 'yoone-subscriptions') . '</p>';
return;
}
echo '<table class="shop_table shop_table_responsive my_account_subscriptions">
<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>
<th>' . esc_html__('开始时间', 'yoone-subscriptions') . '</th>
<th>' . esc_html__('下次续费', 'yoone-subscriptions') . '</th>
</tr>
</thead>
<tbody>';
foreach ($subs as $row) {
$product = wc_get_product(intval($row['product_id']));
$product_name = $product ? $product->get_name() : ('#' . intval($row['product_id']));
$period_label = $row['period'] === 'year' ? __('年', 'yoone-subscriptions') : __('月', 'yoone-subscriptions');
echo '<tr>
<td>' . esc_html($product_name) . '</td>
<td>' . esc_html($period_label) . '</td>
<td>' . esc_html(intval($row['qty'])) . '</td>
<td>' . wc_price(floatval($row['price_per_cycle'])) . '</td>
<td>' . esc_html($row['status']) . '</td>
<td>' . esc_html($row['start_date']) . '</td>
<td>' . esc_html($row['next_renewal_date']) . '</td>
</tr>';
}
echo '</tbody></table>';
}
/** /**
* 渲染订阅选择 UI产品页 * 渲染订阅选择 UI产品页
*/ */

View File

@ -1,6 +1,8 @@
<?php <?php
/** /**
* 简单日志封装:使用 WooCommerce WC_Logger。 * 日志封装:使用 WooCommerce WC_Logger
* - 提供 info / warning / error 三个级别的简洁封装
* - 统一设置 source = 'yoone-subscriptions',便于在日志中筛选来源
*/ */
defined('ABSPATH') || exit; defined('ABSPATH') || exit;

View File

@ -12,6 +12,14 @@
* Text Domain: yoone-subscriptions * Text Domain: yoone-subscriptions
*/ */
/**
* 文件说明:插件主入口
* - 注册国际化、激活/卸载钩子
* - WooCommerce 依赖检查,加载核心、后台、前端、日志、数据库模型模块
* - 资源注册:
* - 后台assets/css/admin.css、assets/js/admin.js产品编辑页
* - 前端assets/css/frontend.css、assets/js/frontend.js产品页与购物车交互
*/
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
// 常量 // 常量
@ -31,6 +39,8 @@ register_activation_hook(__FILE__, function() {
if (class_exists('Yoone_Subscriptions_DB')) { if (class_exists('Yoone_Subscriptions_DB')) {
Yoone_Subscriptions_DB::install(); Yoone_Subscriptions_DB::install();
} }
// 刷新 rewrite 规则,确保 My Account 的 subscriptions 端点生效
flush_rewrite_rules();
}); });
// 卸载钩子注意不可使用匿名函数Closure因为 WordPress 会序列化回调PHP 不允许序列化 Closure。 // 卸载钩子注意不可使用匿名函数Closure因为 WordPress 会序列化回调PHP 不允许序列化 Closure。