Merge pull request 'feat(forum,admin): plate follow-pay MVP, sales column, order type tabs' (#157) from worktree-zibll-native-admin-menus into main

Reviewed-on: CYL/wordpress-theme-aksrt#157
This commit is contained in:
2026-07-15 11:03:58 +08:00
12 changed files with 672 additions and 12 deletions
+105
View File
@@ -718,6 +718,110 @@
});
}
function formatFollowPayLabel(pay) {
pay = pay || {};
if (pay.is_free) {
return 'VIP 免费关注';
}
if (pay.label) {
return String(pay.label);
}
if (pay.modo === 'points') {
return String(pay.amount || 0) + ' 积分';
}
return '¥' + String(pay.amount || '0');
}
function purchasePlateFollow(plateId, button, payHint) {
var confirmMsg = '确认付费关注该版块?';
if (payHint && payHint.is_free) {
confirmMsg = '确认使用 VIP 免费关注该版块?';
} else if (payHint) {
confirmMsg = '确认支付 ' + formatFollowPayLabel(payHint) + ' 关注该版块?';
}
if (!window.confirm(confirmMsg)) {
button.disabled = false;
return;
}
var data = new FormData();
data.append('action', 'aksrt_forum_purchase_plate_follow');
data.append('plate_id', plateId);
data.append('_nonce', rootData.forumNonce || '');
fetch((rootData.ajaxUrl || '/wp-admin/admin-ajax.php'), {
method: 'POST',
body: data,
credentials: 'same-origin',
}).then(function (r) { return r.json(); }).then(function (payload) {
if (!payload.success) {
throw new Error((payload.data && payload.data.message) || '支付失败');
}
var res = payload.data || {};
button.setAttribute('data-followed', '1');
button.classList.add('is-followed');
var label = button.querySelector('span') || button;
if (button.querySelector('i') && button.childNodes.length) {
// keep icon, replace trailing text via last text-ish content
button.innerHTML = '<i class="fas fa-heart"></i> 已关注';
} else {
button.textContent = '已关注';
}
if (typeof window.aksrtToast === 'function') {
window.aksrtToast(res.message || '关注成功', 'success');
}
setTimeout(function () { window.location.reload(); }, 600);
}).catch(function (error) {
var msg = error.message || '支付失败';
if (typeof window.aksrtToast === 'function') { window.aksrtToast(msg, 'error'); }
else { window.alert(msg); }
button.disabled = false;
});
}
function bindFollowPlateButton(button) {
button.addEventListener('click', function () {
var plateId = parseInt(button.getAttribute('data-plate-id') || '0', 10);
if (!plateId || button.disabled) {
return;
}
button.disabled = true;
var data = new FormData();
data.append('action', 'aksrt_forum_follow_plate');
data.append('plate_id', plateId);
data.append('_nonce', rootData.forumNonce || '');
fetch((rootData.ajaxUrl || '/wp-admin/admin-ajax.php'), {
method: 'POST',
body: data,
credentials: 'same-origin',
}).then(function (r) { return r.json(); }).then(function (payload) {
// Paid follow required
if (!payload.success && payload.data && payload.data.require_pay) {
purchasePlateFollow(plateId, button, payload.data.pay || {});
return;
}
if (!payload.success) {
throw new Error((payload.data && payload.data.message) || '操作失败');
}
var res = payload.data || {};
var followed = !!res.followed;
button.setAttribute('data-followed', followed ? '1' : '0');
button.classList.toggle('is-followed', followed);
button.innerHTML = followed
? '<i class="fas fa-heart"></i> 已关注'
: '<i class="fas fa-heart"></i> 关注';
button.disabled = false;
}).catch(function (error) {
var msg = error.message || '操作失败';
if (typeof window.aksrtToast === 'function') { window.aksrtToast(msg, 'error'); }
else { window.alert(msg); }
button.disabled = false;
});
});
}
function bindComposeCoverToggle(form) {
var coverTypeSelect = form.querySelector('#compose-cover-type');
var imageField = form.querySelector('#compose-cover-image-field');
@@ -1455,6 +1559,7 @@
document.querySelectorAll('.js-aksrt-adopt-answer').forEach(bindAdoptButton);
document.querySelectorAll('.js-aksrt-unlock-topic').forEach(bindUnlockButton);
document.querySelectorAll('.js-aksrt-purchase-topic').forEach(bindPurchaseButton);
document.querySelectorAll('.js-aksrt-follow-plate').forEach(bindFollowPlateButton);
bindForumCommentEngine();
bindForumHomeTabs();
bindBoardSortTabs();
+1
View File
@@ -27,6 +27,7 @@ Do **not** reintroduce operational workbenches into the SPA once native pages sh
支付中心 (aksrt_pay) pos ~36–38, dashicons-cart
├─ 概览 aksrt_pay
├─ 订单 aksrt_orders
├─ 付费文章 aksrt_pay_products
├─ 发货及物流 aksrt_shipping
├─ 售后管理 aksrt_after_sale
├─ 优惠码 aksrt_coupons
+2 -2
View File
@@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no
| 2b Shipping/after-sale/coupons/income/rebate | done |
| 3 users.php submenus | done |
| 4 SPA strip | done (ops routes redirect to native) |
| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch + paid-post sales report (`aksrt_pay_products`) + topic pay/points visibility admin/runtime alignment |
| 5 remaining | plate follow-pay (zibll paid follow) still optional; topic-level pay/points/hide-part done |
| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch + paid-post sales report (`aksrt_pay_products`) + topic pay/points visibility admin/runtime alignment + plate follow-pay MVP + post list sales column + order type tabs + topic plate re-check |
| 5 remaining | download-log admin page; topic VIP1/2 full matrix; hidecontent shortcode partial-hide; users commerce columns |
## Dual-UI rule
@@ -77,6 +77,29 @@ class AKSRT_Admin_Pay_Orders {
? AKSRT_Domain_Model::order_status_labels()
: [];
// Type tabs (zibll order subsubsub parity).
$type_tabs = [
'' => __('全部类型', 'aksrt'),
'resource' => __('内容付费', 'aksrt'),
'vip' => __('VIP', 'aksrt'),
'recharge' => __('充值', 'aksrt'),
'shop_product' => __('商城商品', 'aksrt'),
'plate_follow' => __('版块关注', 'aksrt'),
];
echo '<ul class="subsubsub" style="float:none;margin:8px 0 12px;">';
$tab_bits = [];
foreach ($type_tabs as $tkey => $tlabel) {
$url = $this->page_url('aksrt_orders', array_filter([
'product_type' => $tkey !== '' ? $tkey : null,
'product_id' => $product_id > 0 ? $product_id : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
]));
$cls = ($product_type === $tkey) ? 'current' : '';
$tab_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($tlabel) . '</a></li>';
}
echo implode(' | ', $tab_bits);
echo '</ul><div style="clear:both;"></div>';
echo '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="aksrt_orders" />';
if ($product_type !== '') {
+5
View File
@@ -351,6 +351,7 @@ class AKSRT_Forum {
add_action('wp_ajax_aksrt_forum_topic_list', [$this, 'ajax_topic_list']);
add_action('wp_ajax_aksrt_forum_create_topic', [$this, 'ajax_create_topic']);
add_action('wp_ajax_aksrt_forum_follow_plate', [$this, 'ajax_follow_plate']);
add_action('wp_ajax_aksrt_forum_purchase_plate_follow', [$this, 'ajax_purchase_plate_follow']);
add_action('wp_ajax_aksrt_forum_unlock_topic', [$this, 'ajax_unlock_topic']);
add_action('wp_ajax_aksrt_forum_purchase_topic', [$this, 'ajax_purchase_topic']);
@@ -450,6 +451,10 @@ class AKSRT_Forum {
$this->board_service()->ajax_follow_plate();
}
public function ajax_purchase_plate_follow(): void {
$this->board_service()->ajax_purchase_plate_follow();
}
public function ajax_unlock_topic(): void {
$this->topic_service()->ajax_unlock_topic();
}
+59
View File
@@ -456,6 +456,7 @@ class AKSRT_Post_Meta_Box {
public function add_columns(array $columns): array {
$columns['aksrt_views'] = __('阅读量', 'aksrt');
$columns['aksrt_likes'] = __('点赞数', 'aksrt');
$columns['aksrt_sales'] = __('销售数据', 'aksrt');
return $columns;
}
@@ -473,9 +474,67 @@ class AKSRT_Post_Meta_Box {
$toc = esc_attr($meta['show_toc'] ?? '');
$mh = (int) ($meta['maxheight'] ?? 0);
echo '<span class="aksrt-col-data" data-views="' . (int) ($meta['views'] ?? 0) . '" data-likes="' . $likes . '" data-layout="' . $layout . '" data-toc="' . $toc . '" data-maxheight="' . $mh . '">' . esc_html($likes > 0 ? (string) $likes : '—') . '</span>';
} elseif ($column === 'aksrt_sales') {
$this->render_sales_column($post_id);
}
}
/**
* Content-pay sales summary for posts list (zibll 销售数据 column parity).
*/
private function render_sales_column(int $post_id): void {
if (!function_exists('aksrt_get_pay_meta') || !class_exists('AKSRT_Domain_Model')) {
echo '—';
return;
}
$pay = aksrt_get_pay_meta($post_id);
$pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($pay['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO));
if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) {
echo '<span class="description">' . esc_html__('免费', 'aksrt') . '</span>';
return;
}
$labels = AKSRT_Domain_Model::pay_type_labels();
$type_label = $labels[$pay_type] ?? $pay_type;
$modo = (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : '0';
if ($modo === 'points') {
$price_text = sprintf(__('%d 积分', 'aksrt'), (int) ceil((float) ($pay['points_price'] ?? $pay['price'] ?? 0)));
} else {
$price_text = '¥' . number_format((float) ($pay['price'] ?? 0), 2);
}
$meta_sales = (int) get_post_meta($post_id, AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY, true);
$db_sales = 0;
$revenue = 0.0;
if (class_exists('AKSRT_Order_Repository')) {
$map = AKSRT_Order_Repository::get_instance()->get_content_sales_map(
[$post_id],
AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE
);
if (isset($map[$post_id])) {
$db_sales = (int) ($map[$post_id]['sales_count'] ?? 0);
$revenue = (float) ($map[$post_id]['revenue'] ?? 0);
}
}
$orders_url = admin_url('admin.php?page=aksrt_orders&product_type=resource&product_id=' . $post_id);
$products_url = admin_url('admin.php?page=aksrt_pay_products&s=' . $post_id);
echo '<div style="font-size:12px;line-height:1.5;">';
echo '<strong style="color:#0c9cc8;">' . esc_html((string) $type_label) . '</strong>';
echo '<br>' . esc_html($price_text);
echo '<br>' . esc_html(sprintf(__('Meta销量 %d', 'aksrt'), max(0, $meta_sales)));
if ($db_sales > 0) {
printf(
'<br><a href="%s" title="%s">%s</a>',
esc_url($orders_url),
esc_attr__('查看销售明细', 'aksrt'),
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, number_format($revenue, 2)))
);
} else {
echo '<br><span class="description">' . esc_html__('暂无订单', 'aksrt') . '</span>';
}
echo '<br><a href="' . esc_url($products_url) . '">' . esc_html__('付费文章', 'aksrt') . '</a>';
echo '</div>';
}
/**
* §3.8 Render quick edit and bulk edit fields.
*/
@@ -295,11 +295,14 @@ trait AKSRT_Forum_Board_Service_Part_2 {
/**
* Toggle user follow on a plate (§7.2 关注/取消关注).
*
* @return array{followed:bool,follow_count:int}
* When plate has paid follow enabled and user is not following, returns
* require_pay payload instead of free-following.
*
* @return array{followed?:bool,follow_count?:int,require_pay?:bool,pay?:array,message?:string,success?:bool}
*/
public function toggle_follow_plate(int $user_id, int $plate_id): array {
if ($user_id <= 0) {
return ['followed' => false, 'follow_count' => 0];
return ['followed' => false, 'follow_count' => 0, 'success' => false, 'message' => __('请先登录', 'aksrt')];
}
$follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true);
@@ -309,23 +312,332 @@ trait AKSRT_Forum_Board_Service_Part_2 {
$followed = in_array($plate_id, $follow_list, true);
if ($followed) {
// Unfollow is always free (zibll warns but still allows cancel).
$follow_list = array_values(array_diff($follow_list, [$plate_id]));
} else {
$follow_list[] = $plate_id;
update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list))));
$count = $this->count_plate_followers($plate_id);
update_post_meta($plate_id, '_aksrt_follow_count', $count);
return [
'followed' => false,
'follow_count' => $count,
'success' => true,
];
}
// Follow path — paid gate.
if ($this->is_plate_follow_pay_required($plate_id, $user_id)) {
$price = $this->resolve_plate_follow_pay_price($plate_id, $user_id);
return [
'followed' => false,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
'require_pay' => true,
'success' => false,
'message' => __('关注该版块需要付费', 'aksrt'),
'pay' => $price,
];
}
$follow_list[] = $plate_id;
update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list))));
// Update plate follow_count
$count = $this->count_plate_followers($plate_id);
update_post_meta($plate_id, '_aksrt_follow_count', $count);
return [
'followed' => !$followed,
'followed' => true,
'follow_count' => $count,
'success' => true,
];
}
/**
* Whether global+plate paid follow is on and user still needs to pay.
*/
public function is_plate_follow_pay_required(int $plate_id, int $user_id): bool {
if ($user_id <= 0 || $plate_id <= 0) {
return false;
}
if ($this->is_user_following_plate($user_id, $plate_id)) {
return false;
}
if ($this->is_plate_moderator($user_id, $plate_id) || user_can($user_id, 'manage_options')) {
return false;
}
if (!$this->is_plate_follow_pay_enabled($plate_id)) {
return false;
}
// Already purchased plate_follow order.
if (class_exists('AKSRT_Order_Repository') && class_exists('AKSRT_Domain_Model')) {
if (AKSRT_Order_Repository::get_instance()->has_paid_access(
$user_id,
AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW,
$plate_id
)) {
return false;
}
}
return true;
}
public function is_plate_follow_pay_enabled(int $plate_id): bool {
if ($plate_id <= 0) {
return false;
}
// Global switch from forum settings (SPA).
$global_on = true;
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$cfg = AKSRT_Settings::get_forum_settings();
// Nested permissions.plate_follow_pay or flat.
if (isset($cfg['permissions']['plate_follow_pay'])) {
$global_on = !empty($cfg['permissions']['plate_follow_pay']);
} elseif (isset($cfg['plate_follow_pay'])) {
$global_on = !empty($cfg['plate_follow_pay']);
}
}
if (!$global_on) {
return false;
}
if ((int) get_post_meta($plate_id, '_aksrt_follow_pay', true) !== 1) {
return false;
}
$modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money'));
if ($modo === 'points') {
return (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true) > 0;
}
return (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true) > 0;
}
/**
* @return array{amount:float|int,modo:string,is_vip_price:bool,is_free:bool,label:string,base:float|int,currency:string}
*/
public function resolve_plate_follow_pay_price(int $plate_id, int $user_id = 0): array {
$modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money'));
if (!in_array($modo, ['money', 'points'], true)) {
$modo = 'money';
}
$is_points = $modo === 'points';
$base = $is_points
? max(0, (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true))
: max(0.0, (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true));
$vip_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true);
$vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false);
$vip_price = $vip_set
? ($is_points ? max(0, (int) $vip_raw) : max(0.0, (float) $vip_raw))
: 0;
$is_vip = $user_id > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($user_id);
if (!$is_vip && $user_id > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($user_id)) {
$is_vip = true;
}
if ($is_vip && $vip_set) {
$amount = $vip_price;
return [
'amount' => $amount,
'modo' => $modo,
'is_vip_price' => true,
'is_free' => $amount <= 0,
'label' => $amount <= 0
? __('VIP 免费关注', 'aksrt')
: ($is_points
? sprintf(__('VIP %d 积分', 'aksrt'), (int) $amount)
: sprintf(__('VIP ¥%s', 'aksrt'), number_format((float) $amount, 2, '.', ''))),
'base' => $base,
'currency' => $is_points ? 'points' : 'CNY',
];
}
return [
'amount' => $base,
'modo' => $modo,
'is_vip_price' => false,
'is_free' => false,
'label' => $is_points
? sprintf(__('%d 积分', 'aksrt'), (int) $base)
: sprintf(__('¥%s', 'aksrt'), number_format((float) $base, 2, '.', '')),
'base' => $base,
'currency' => $is_points ? 'points' : 'CNY',
];
}
/**
* Force-add plate to user follow list (after paid success).
*/
public function mark_plate_followed(int $user_id, int $plate_id): void {
if ($user_id <= 0 || $plate_id <= 0) {
return;
}
$follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true);
if (!is_array($follow_list)) {
$follow_list = [];
}
$follow_list[] = $plate_id;
update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list))));
$count = $this->count_plate_followers($plate_id);
update_post_meta($plate_id, '_aksrt_follow_count', $count);
}
/**
* Purchase plate follow with points or balance (creates plate_follow order when cash).
*
* @return array{success:bool,message:string,followed?:bool,follow_count?:int,order_number?:string}
*/
public function purchase_plate_follow(int $user_id, int $plate_id): array {
if ($user_id <= 0) {
return $this->forum->error(__('请先登录', 'aksrt'));
}
$post = get_post($plate_id);
if (!$post || $post->post_type !== 'aksrt_forum_plate') {
return $this->forum->error(__('版块不存在', 'aksrt'));
}
if ($this->is_user_following_plate($user_id, $plate_id)) {
return $this->forum->success(__('已关注', 'aksrt'), [
'followed' => true,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
]);
}
if (!$this->is_plate_follow_pay_enabled($plate_id)) {
// Free follow fallback.
$this->mark_plate_followed($user_id, $plate_id);
return $this->forum->success(__('关注成功', 'aksrt'), [
'followed' => true,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
]);
}
$price = $this->resolve_plate_follow_pay_price($plate_id, $user_id);
if (!empty($price['is_free'])) {
$this->mark_plate_followed($user_id, $plate_id);
return $this->forum->success(__('VIP 免费关注成功', 'aksrt'), [
'followed' => true,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
'free' => true,
]);
}
if (!class_exists('AKSRT_Credits')) {
return $this->forum->error(__('积分系统不可用', 'aksrt'));
}
$modo = (string) ($price['modo'] ?? 'money');
$amount = $price['amount'] ?? 0;
if ($modo === 'points') {
$cost = max(0, (int) $amount);
if ($cost <= 0) {
return $this->forum->error(__('积分价格未设置', 'aksrt'));
}
$balance = AKSRT_Credits::get_balance($user_id);
if ($balance < $cost) {
return $this->forum->error(sprintf(__('积分不足,需要 %d 积分', 'aksrt'), $cost));
}
$deducted = AKSRT_Credits::deduct($user_id, $cost, 'forum_plate_follow', [
'plate_id' => $plate_id,
], sprintf(__('付费关注版块「%s」', 'aksrt'), get_the_title($plate_id)));
if (is_wp_error($deducted) || $deducted === false) {
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt');
return $this->forum->error($msg ?: __('积分扣除失败', 'aksrt'));
}
$this->mark_plate_followed($user_id, $plate_id);
$this->grant_plate_follow_author_share($user_id, $plate_id, $cost, true);
return $this->forum->success(__('关注成功', 'aksrt'), [
'followed' => true,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
'credits_cost' => $cost,
]);
}
// Cash via balance credits (same as topic pay).
if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Domain_Model')) {
return $this->forum->error(__('支付系统不可用', 'aksrt'));
}
$cash = max(0.0, (float) $amount);
if ($cash <= 0) {
return $this->forum->error(__('价格未设置', 'aksrt'));
}
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW;
$order_number = AKSRT_Order::get_instance()->create($user_id, $product_type, $plate_id, $cash, 'CNY');
if ($order_number === '') {
return $this->forum->error(__('创建订单失败', 'aksrt'));
}
$credits_cost = (int) ceil($cash * 100);
$balance = AKSRT_Credits::get_balance($user_id);
if ($balance < $credits_cost) {
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [
'note' => __('余额不足,自动取消', 'aksrt'),
]);
return $this->forum->error(sprintf(
__('余额不足,需要 %d 积分(约 ¥%s),当前余额 %d', 'aksrt'),
$credits_cost,
number_format($cash, 2, '.', ''),
$balance
));
}
$deducted = AKSRT_Credits::deduct(
$user_id,
$credits_cost,
'forum_plate_follow_pay',
['plate_id' => $plate_id, 'order' => $order_number],
sprintf(__('付费关注版块「%s」', 'aksrt'), get_the_title($plate_id))
);
if (is_wp_error($deducted) || $deducted === false) {
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [
'note' => __('扣款失败,自动取消', 'aksrt'),
]);
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('扣款失败', 'aksrt');
return $this->forum->error($msg ?: __('扣款失败', 'aksrt'));
}
$paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [
'payment_method' => 'balance',
'payment_transaction_id' => 'BAL_' . $order_number,
'note' => sprintf(__('版块付费关注余额支付 %d 积分', 'aksrt'), $credits_cost),
]);
if (!$paid) {
AKSRT_Credits::add(
$user_id,
$credits_cost,
'forum_plate_follow_refund',
['plate_id' => $plate_id, 'order' => $order_number],
sprintf(__('关注失败退款:%s', 'aksrt'), get_the_title($plate_id))
);
return $this->forum->error(__('订单确认失败,已尝试退回积分', 'aksrt'));
}
$this->mark_plate_followed($user_id, $plate_id);
return $this->forum->success(__('关注成功', 'aksrt'), [
'followed' => true,
'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true),
'order_number' => $order_number,
'amount' => $cash,
'credits_cost' => $credits_cost,
]);
}
private function grant_plate_follow_author_share(int $buyer_id, int $plate_id, int $cost, bool $is_points): void {
if (!$is_points || $cost <= 0 || !class_exists('AKSRT_Credits')) {
return;
}
$post = get_post($plate_id);
if (!$post) {
return;
}
$author_id = (int) $post->post_author;
if ($author_id <= 0 || $author_id === $buyer_id) {
return;
}
$author_share = max(0, (int) ($this->forum->get_forum_config()['credits']['author_share_percent'] ?? 70));
$author_amount = (int) floor($cost * $author_share / 100);
if ($author_amount <= 0) {
return;
}
AKSRT_Credits::add($author_id, $author_amount, 'forum_plate_follow_income', [
'plate_id' => $plate_id,
'buyer_id' => $buyer_id,
], sprintf(__('版块「%s」被付费关注', 'aksrt'), get_the_title($plate_id)));
}
/**
* Count followers for a plate.
*/
@@ -132,6 +132,43 @@ trait AKSRT_Forum_Board_Service_Part_3 {
}
$result = $this->toggle_follow_plate($user_id, $plate_id);
if (!empty($result['require_pay'])) {
wp_send_json_error([
'code' => 'require_pay',
'message' => (string) ($result['message'] ?? __('需要付费关注', 'aksrt')),
'require_pay' => true,
'pay' => $result['pay'] ?? [],
'followed' => false,
'follow_count' => (int) ($result['follow_count'] ?? 0),
]);
}
if (isset($result['success']) && $result['success'] === false) {
wp_send_json_error([
'message' => (string) ($result['message'] ?? __('操作失败', 'aksrt')),
]);
}
wp_send_json_success($result);
}
public function ajax_purchase_plate_follow(): void {
check_ajax_referer('aksrt_forum_action', '_nonce');
$user_id = get_current_user_id();
if ($user_id <= 0) {
wp_send_json_error(['message' => __('请先登录', 'aksrt')]);
}
$plate_id = (int) ($_POST['plate_id'] ?? 0);
if ($plate_id <= 0) {
wp_send_json_error(['message' => __('版块不存在', 'aksrt')]);
}
$result = $this->purchase_plate_follow($user_id, $plate_id);
if (empty($result['success'])) {
wp_send_json_error([
'message' => (string) ($result['message'] ?? __('支付失败', 'aksrt')),
]);
}
wp_send_json_success($result);
}
}
@@ -89,8 +89,10 @@ class AKSRT_Plate_Meta_Box {
$wp_roles = $this->wp_role_options();
$icon = (string) get_post_meta($plate_id, '_aksrt_icon', true);
$sort_order = (int) get_post_meta($plate_id, '_aksrt_sort_order', true);
$follow_pay = $this->get_follow_pay_meta($plate_id);
$this->render_access_fields($allow_view, $add_limit, $view_roles, $add_roles, $wp_roles);
$this->render_follow_pay_fields($follow_pay);
$this->render_display_fields($icon, $sort_order);
$this->render_moderator_fields($moderator_ids, $roles_map, $author_id);
$this->render_applications($applications);
@@ -151,6 +153,42 @@ class AKSRT_Plate_Meta_Box {
<?php
}
/**
* @param array{enabled:bool,modo:string,price:float,points:int,vip_price:string} $follow_pay
*/
private function render_follow_pay_fields(array $follow_pay): void {
?>
<p>
<label>
<input type="checkbox" name="aksrt_plate_follow_pay_enabled" value="1" <?php checked(!empty($follow_pay['enabled'])); ?>>
<strong><?php esc_html_e('付费关注', 'aksrt'); ?></strong>
</label>
<span class="description" style="display:block;"><?php esc_html_e('开启后,未关注用户需支付才能关注(与「关注可见」配合使用)。', 'aksrt'); ?></span>
</p>
<p>
<label for="aksrt_plate_follow_pay_modo"><strong><?php esc_html_e('关注支付方式', 'aksrt'); ?></strong></label>
<select id="aksrt_plate_follow_pay_modo" name="aksrt_plate_follow_pay_modo" class="widefat">
<option value="money" <?php selected(($follow_pay['modo'] ?? 'money'), 'money'); ?>><?php esc_html_e('现金(余额)', 'aksrt'); ?></option>
<option value="points" <?php selected(($follow_pay['modo'] ?? ''), 'points'); ?>><?php esc_html_e('积分', 'aksrt'); ?></option>
</select>
</p>
<p>
<label for="aksrt_plate_follow_pay_price"><strong><?php esc_html_e('现金价格(元)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_plate_follow_pay_price" id="aksrt_plate_follow_pay_price" value="<?php echo esc_attr((string) ($follow_pay['price'] ?? 0)); ?>">
</p>
<p>
<label for="aksrt_plate_follow_pay_points"><strong><?php esc_html_e('积分价格', 'aksrt'); ?></strong></label>
<input type="number" step="1" min="0" class="widefat" name="aksrt_plate_follow_pay_points" id="aksrt_plate_follow_pay_points" value="<?php echo esc_attr((string) (int) ($follow_pay['points'] ?? 0)); ?>">
</p>
<p>
<label for="aksrt_plate_follow_pay_vip_price"><strong><?php esc_html_e('VIP 价格(可选)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_plate_follow_pay_vip_price" id="aksrt_plate_follow_pay_vip_price" value="<?php echo esc_attr((string) ($follow_pay['vip_price'] ?? '')); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP 价;0=VIP 免费', 'aksrt'); ?>">
<span class="description"><?php esc_html_e('现金模式用元;积分模式填积分数。', 'aksrt'); ?></span>
</p>
<hr>
<?php
}
private function render_display_fields(string $icon, int $sort_order): void {
?>
<p>
@@ -280,6 +318,7 @@ class AKSRT_Plate_Meta_Box {
}
$this->save_access_meta($post_id);
$this->save_follow_pay_meta($post_id);
$this->save_display_meta($post_id);
$this->save_moderators($post_id, (int) $post->post_author);
$this->save_applications();
@@ -325,6 +364,70 @@ class AKSRT_Plate_Meta_Box {
}
}
private function save_follow_pay_meta(int $post_id): void {
$enabled = !empty($_POST['aksrt_plate_follow_pay_enabled']);
$modo = isset($_POST['aksrt_plate_follow_pay_modo'])
? sanitize_key((string) wp_unslash($_POST['aksrt_plate_follow_pay_modo']))
: 'money';
if (!in_array($modo, ['money', 'points'], true)) {
$modo = 'money';
}
$price = isset($_POST['aksrt_plate_follow_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_price'])) : 0;
$points = isset($_POST['aksrt_plate_follow_pay_points']) ? max(0, (int) wp_unslash($_POST['aksrt_plate_follow_pay_points'])) : 0;
// Invalid price while enabled → force off (normalize, no migration).
if ($enabled) {
if ($modo === 'points' && $points <= 0) {
$enabled = false;
}
if ($modo === 'money' && $price <= 0) {
$enabled = false;
}
}
if (!$enabled) {
delete_post_meta($post_id, '_aksrt_follow_pay');
delete_post_meta($post_id, '_aksrt_follow_pay_modo');
delete_post_meta($post_id, '_aksrt_follow_pay_price');
delete_post_meta($post_id, '_aksrt_follow_pay_points');
delete_post_meta($post_id, '_aksrt_follow_pay_vip_price');
return;
}
update_post_meta($post_id, '_aksrt_follow_pay', 1);
update_post_meta($post_id, '_aksrt_follow_pay_modo', $modo);
update_post_meta($post_id, '_aksrt_follow_pay_price', $price);
update_post_meta($post_id, '_aksrt_follow_pay_points', $points);
if (isset($_POST['aksrt_plate_follow_pay_vip_price']) && (string) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price']) !== '') {
$vip = max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price']));
update_post_meta($post_id, '_aksrt_follow_pay_vip_price', $vip);
} else {
delete_post_meta($post_id, '_aksrt_follow_pay_vip_price');
}
}
/**
* @return array{enabled:bool,modo:string,price:float,points:int,vip_price:string}
*/
private function get_follow_pay_meta(int $plate_id): array {
$enabled = (int) get_post_meta($plate_id, '_aksrt_follow_pay', true) === 1;
$modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money'));
if (!in_array($modo, ['money', 'points'], true)) {
$modo = 'money';
}
$vip_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true);
$vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false);
return [
'enabled' => $enabled,
'modo' => $modo,
'price' => (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true),
'points' => (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true),
'vip_price' => $vip_set ? (string) (float) $vip_raw : '',
];
}
private function save_moderators(int $post_id, int $author_id): void {
$role_posted = isset($_POST['aksrt_plate_mod_role']) && is_array($_POST['aksrt_plate_mod_role'])
? wp_unslash($_POST['aksrt_plate_mod_role'])
@@ -166,6 +166,14 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
return true;
}
// Plate gate first (zibll plate → post). Moderators of plate already pass can_view_plate.
$plate_id = (int) get_post_meta((int) $post->ID, '_aksrt_plate_id', true);
if ($plate_id > 0 && method_exists($this->forum, 'can_view_plate')) {
if (!$this->forum->can_view_plate($viewer_user_id, $plate_id)) {
return false;
}
}
if ($visibility === AKSRT_Forum::VISIBILITY_PUBLIC) {
return true;
}
+5
View File
@@ -372,6 +372,11 @@ function aksrt_register_forum_topic_meta() {
'_aksrt_last_post' => ['type' => 'string', 'default' => ''],
'_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'],
'_aksrt_add_limit' => ['type' => 'integer', 'default' => 0],
'_aksrt_follow_pay' => ['type' => 'integer', 'default' => 0],
'_aksrt_follow_pay_modo' => ['type' => 'string', 'default' => 'money'],
'_aksrt_follow_pay_price' => ['type' => 'number', 'default' => 0],
'_aksrt_follow_pay_points' => ['type' => 'integer', 'default' => 0],
'_aksrt_follow_pay_vip_price' => ['type' => 'number', 'default' => 0],
];
foreach ($plate_meta as $key => $args) {
+6 -4
View File
@@ -27,10 +27,12 @@ class AKSRT_Domain_Model {
public const ORDER_MIGRATED_OPTION = 'aksrt_order_migrated_to_table';
public const RESOURCE_PAY_META_MIGRATED = 'aksrt_resource_pay_meta_migrated';
public const ORDER_PRODUCT_RESOURCE = 'resource';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
public const ORDER_PRODUCT_SHOP = 'shop_product';
public const ORDER_PRODUCT_RESOURCE = 'resource';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
public const ORDER_PRODUCT_SHOP = 'shop_product';
/** Paid plate follow (zibll order_type=11). product_id = plate post ID. */
public const ORDER_PRODUCT_PLATE_FOLLOW = 'plate_follow';
public const PAY_TYPE_NO = 'no';
public const PAY_TYPE_READING = 'reading';