diff --git a/assets/css/frontend/forum/board.css b/assets/css/frontend/forum/board.css index 2c6c02c9..039319e3 100644 --- a/assets/css/frontend/forum/board.css +++ b/assets/css/frontend/forum/board.css @@ -224,7 +224,7 @@ .aksrt-forum-board__application-reason { margin: 10px 0; padding: 10px 12px; - border-left: 3px solid var(--color-primary, #1890ff); + border-left: 3px solid color-mix(in srgb, var(--color-primary) 55%, transparent); background: var(--surface-2, #f8fafc); color: var(--color-ink-3, #64748b); font-size: 13px; @@ -240,7 +240,7 @@ padding: 12px 16px; background: var(--surface-1, #fff); border-radius: 8px; - border-left: 3px solid var(--color-primary); + border-left: 3px solid color-mix(in srgb, var(--color-primary) 55%, transparent); } .aksrt-forum-board__sticky h3 { diff --git a/assets/css/frontend/forum/compose.css b/assets/css/frontend/forum/compose.css index e3d08ec8..77710245 100644 --- a/assets/css/frontend/forum/compose.css +++ b/assets/css/frontend/forum/compose.css @@ -489,8 +489,9 @@ } .aksrt-forum-compose__draft:hover { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: var(--color-primary); + background: var(--color-primary-softer, color-mix(in srgb, var(--color-primary) 6%, transparent)); } /* ── topic cover (§7.3) ── */ @@ -646,7 +647,7 @@ } .aksrt-forum-compose__cover-btn:hover { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: var(--color-primary); background: var(--color-primary-soft); } diff --git a/assets/css/frontend/forum/extras/additional-role-badges.css b/assets/css/frontend/forum/extras/additional-role-badges.css index f732d747..b02887c9 100644 --- a/assets/css/frontend/forum/extras/additional-role-badges.css +++ b/assets/css/frontend/forum/extras/additional-role-badges.css @@ -43,7 +43,7 @@ } .aksrt-forum-reply.is-pinned { - border-left: 3px solid #1890ff; + border-left: 3px solid color-mix(in srgb, var(--color-primary) 55%, transparent); } .aksrt-forum-reply.is-pinned.is-hot { diff --git a/assets/css/frontend/forum/extras/base.css b/assets/css/frontend/forum/extras/base.css index 5432556c..645983f7 100644 --- a/assets/css/frontend/forum/extras/base.css +++ b/assets/css/frontend/forum/extras/base.css @@ -253,7 +253,7 @@ .aksrt-forum-topic__badge--question { background: #e6f7ff; - color: #1890ff; + color: var(--color-primary); } .aksrt-forum-topic__badge--solved { @@ -263,7 +263,7 @@ .aksrt-forum-topic-item__badge--question { background: #e6f7ff; - color: #1890ff; + color: var(--color-primary); } .aksrt-forum-topic-item__badge--solved { @@ -328,8 +328,8 @@ } .aksrt-forum-topic__score-btn:hover:not(:disabled) { - border-color: var(--color-primary, #1890ff); - color: var(--color-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + color: var(--color-primary); } .aksrt-forum-topic__score-btn--up:hover:not(:disabled) { @@ -372,7 +372,7 @@ .aksrt-forum-topic__vote-type-badge { display: inline-block; padding: 1px 6px; - background: var(--color-primary, #1890ff); + background: var(--color-primary); color: #fff; border-radius: var(--radius-pill, 999px); font-size: 11px; @@ -401,7 +401,7 @@ .aksrt-forum-topic__chip--topic-cat { background: var(--color-primary-bg, #e6f7ff); - color: var(--color-primary, #1890ff); + color: var(--color-primary); font-weight: 600; } diff --git a/assets/css/frontend/forum/extras/like-button.css b/assets/css/frontend/forum/extras/like-button.css index 7fd51987..fd7099ce 100644 --- a/assets/css/frontend/forum/extras/like-button.css +++ b/assets/css/frontend/forum/extras/like-button.css @@ -14,11 +14,11 @@ } .aksrt-forum-reply__like-btn:hover { - color: var(--c-primary, #1890ff); + color: var(--color-primary); } .aksrt-forum-reply__like-btn.is-active { - color: var(--c-primary, #1890ff); + color: var(--color-primary); } .aksrt-forum-reply__like-count { diff --git a/assets/css/frontend/forum/extras/load-more.css b/assets/css/frontend/forum/extras/load-more.css index dd19e8e7..9a201bc9 100644 --- a/assets/css/frontend/forum/extras/load-more.css +++ b/assets/css/frontend/forum/extras/load-more.css @@ -17,7 +17,7 @@ } .aksrt-forum-replies__more-btn:hover { - border-color: var(--c-primary, #1890ff); - color: var(--c-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + color: var(--color-primary); } diff --git a/assets/css/frontend/forum/extras/quick-post-bar.css b/assets/css/frontend/forum/extras/quick-post-bar.css index 5fddff0c..8e151fe1 100644 --- a/assets/css/frontend/forum/extras/quick-post-bar.css +++ b/assets/css/frontend/forum/extras/quick-post-bar.css @@ -19,8 +19,8 @@ } .aksrt-forum-quick-post__input:hover { - border-color: var(--c-primary, #1890ff); - box-shadow: 0 0 0 2px rgba(24, 144, 255, .1); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent); } .aksrt-forum-quick-post__input img { diff --git a/assets/css/frontend/forum/extras/reply-action-buttons.css b/assets/css/frontend/forum/extras/reply-action-buttons.css index d196c3d1..7118ef22 100644 --- a/assets/css/frontend/forum/extras/reply-action-buttons.css +++ b/assets/css/frontend/forum/extras/reply-action-buttons.css @@ -16,7 +16,7 @@ .aksrt-forum-reply__reply-btn:hover, .aksrt-forum-reply__top-btn:hover { - color: var(--c-primary, #1890ff); + color: var(--color-primary); } /* ── §7.3 Hidden content block [aksrt_hide] ── */ @@ -80,13 +80,13 @@ .aksrt-forum-home-tabs__btn:hover { color: var(--c-text-1, #333); - border-color: var(--c-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-forum-home-tabs__btn.is-active { - background: var(--c-primary, #1890ff); + background: var(--color-primary); color: #fff; - border-color: var(--c-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-forum-home-tabs__badge { diff --git a/assets/css/frontend/forum/extras/reply-header-tabs-filters.css b/assets/css/frontend/forum/extras/reply-header-tabs-filters.css index 60655ed8..5bb6e20f 100644 --- a/assets/css/frontend/forum/extras/reply-header-tabs-filters.css +++ b/assets/css/frontend/forum/extras/reply-header-tabs-filters.css @@ -30,13 +30,13 @@ .aksrt-forum-replies__tab:hover { color: var(--c-text-1, #333); - border-color: var(--c-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-forum-replies__tab.is-active { - background: var(--c-primary, #1890ff); + background: var(--color-primary); color: #fff; - border-color: var(--c-primary, #1890ff); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-forum-replies__filter { diff --git a/assets/css/frontend/forum/topic.css b/assets/css/frontend/forum/topic.css index c0fca127..82bb2fdd 100644 --- a/assets/css/frontend/forum/topic.css +++ b/assets/css/frontend/forum/topic.css @@ -266,7 +266,10 @@ } .aksrt-forum-topic__vote-option.is-selected { - outline: 1px solid var(--color-primary); + outline: none; + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + background: var(--color-primary-soft, color-mix(in srgb, var(--color-primary) 12%, transparent)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent); } .aksrt-forum-topic__vote-count { diff --git a/assets/css/frontend/pages/topic-rows.css b/assets/css/frontend/pages/topic-rows.css index fe6336e7..6aeafc37 100644 --- a/assets/css/frontend/pages/topic-rows.css +++ b/assets/css/frontend/pages/topic-rows.css @@ -17,7 +17,8 @@ } .aksrt-forum-topic-row:hover { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + box-shadow: var(--shadow-sm); } .aksrt-forum-topic-row__avatar img { diff --git a/assets/css/frontend/product-archive.css b/assets/css/frontend/product-archive.css index 91ae9662..fdc26368 100644 --- a/assets/css/frontend/product-archive.css +++ b/assets/css/frontend/product-archive.css @@ -87,7 +87,7 @@ .aksrt-product-card:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-product-card__link { @@ -205,13 +205,13 @@ } .aksrt-product-archive__pagination .page-numbers:hover { - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: var(--color-primary, var(--color-primary)); } .aksrt-product-archive__pagination .page-numbers.current { background: var(--color-primary, var(--color-primary)); - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: #fff; } @@ -506,7 +506,7 @@ } .aksrt-cart__qty-btn:hover { - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-cart__item-subtotal { @@ -675,7 +675,8 @@ .aksrt-product-edit__field input:focus, .aksrt-product-edit__field textarea:focus { outline: none; - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent); } .aksrt-product-edit__field textarea { @@ -717,7 +718,7 @@ } .aksrt-product-edit__actions .btn-ghost:hover { - border-color: var(--color-primary, var(--color-primary)); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-product-archive__toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px} diff --git a/assets/css/routes/cart.css b/assets/css/routes/cart.css index 9e7e8e71..c7035b0e 100644 --- a/assets/css/routes/cart.css +++ b/assets/css/routes/cart.css @@ -389,3 +389,12 @@ gap: 12px; padding-top: 8px; } + +/* Soft focus ring (#77 recipe) — avoid hard primary stroke */ +.aksrt-cart-page__input:focus, +.aksrt-cart-page__textarea:focus, +.aksrt-cart-page__qty-btn:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent); +} diff --git a/assets/css/routes/commerce.css b/assets/css/routes/commerce.css index 21368a44..fe9e4131 100644 --- a/assets/css/routes/commerce.css +++ b/assets/css/routes/commerce.css @@ -43,13 +43,13 @@ .aksrt-commerce-header__nav-item:hover { color: var(--color-primary, #f04494); - border-color: color-mix(in srgb, var(--color-primary, #f04494) 35%, transparent); + border-color: color-mix(in srgb, var(--color-primary, #f04494) 45%, transparent); } .aksrt-commerce-header__nav-item.is-active { color: var(--color-on-primary, #fff); background: var(--color-primary, #f04494); - border-color: var(--color-primary, #f04494); + border-color: transparent; } /* Soften revenue dashboard cards — task-first, not ops console */ diff --git a/assets/css/routes/product.css b/assets/css/routes/product.css index beeedf78..153f5094 100644 --- a/assets/css/routes/product.css +++ b/assets/css/routes/product.css @@ -100,7 +100,7 @@ .aksrt-product-page__thumb:hover, .aksrt-product-page__thumb.is-active { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); } .aksrt-product-page__thumb img { @@ -227,8 +227,8 @@ .aksrt-product-editor__input:focus, .aksrt-product-editor__textarea:focus { outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px var(--color-primary-soft); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent); } /* ===== Quantity controls ===== */ @@ -244,7 +244,7 @@ } .aksrt-product-page__qty-btn:hover { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); background: var(--color-primary-softer); } @@ -425,13 +425,13 @@ html.dark .aksrt-product-page__content img { } .aksrt-product-page__spec-btn:hover { - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: var(--color-primary); } .aksrt-product-page__spec-btn.is-active { background: var(--color-primary-soft); - border-color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb)); color: var(--color-primary); font-weight: 600; } diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index 3b348bc0..61216627 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -675,6 +675,49 @@ }); } + function bindPurchaseButton(button) { + button.addEventListener('click', function () { + var topicId = parseInt(button.getAttribute('data-topic-id') || '0', 10); + var price = button.getAttribute('data-price') || '0'; + var isFree = button.getAttribute('data-free') === '1'; + var feedback = button.parentElement ? button.parentElement.querySelector('.js-aksrt-action-feedback') : null; + + if (!topicId) { + return; + } + + var confirmMsg = isFree + ? '确认使用 VIP 免费解锁此主题?' + : ('确认使用余额支付 ¥' + price + ' 购买此主题?'); + if (!window.confirm(confirmMsg)) { + return; + } + + button.disabled = true; + setFeedback(feedback, isFree ? '正在解锁…' : '正在支付…', 'is-loading'); + + var data = new FormData(); + data.append('action', 'aksrt_forum_purchase_topic'); + data.append('topic_id', topicId); + 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) || '购买失败'); + } + setFeedback(feedback, '购买成功,正在刷新…', 'is-success'); + setTimeout(function () { window.location.reload(); }, 800); + }).catch(function (error) { + setFeedback(feedback, error.message || '购买失败', 'is-error'); + button.disabled = false; + }); + }); + } + function bindComposeCoverToggle(form) { var coverTypeSelect = form.querySelector('#compose-cover-type'); var imageField = form.querySelector('#compose-cover-image-field'); @@ -1411,6 +1454,7 @@ document.querySelectorAll('.js-aksrt-topic-score').forEach(bindScoreButton); document.querySelectorAll('.js-aksrt-adopt-answer').forEach(bindAdoptButton); document.querySelectorAll('.js-aksrt-unlock-topic').forEach(bindUnlockButton); + document.querySelectorAll('.js-aksrt-purchase-topic').forEach(bindPurchaseButton); bindForumCommentEngine(); bindForumHomeTabs(); bindBoardSortTabs(); diff --git a/inc/classes/class-aksrt-credits.php b/inc/classes/class-aksrt-credits.php index c67ba073..8e34d967 100644 --- a/inc/classes/class-aksrt-credits.php +++ b/inc/classes/class-aksrt-credits.php @@ -33,6 +33,13 @@ class AKSRT_Credits { return is_numeric($val) ? (int) $val : 0; } + /** + * Alias used by forum unlock/purchase paths. + */ + public static function get_balance(int $user_id): int { + return self::balance($user_id); + } + /** * 增加积分。 * diff --git a/inc/classes/class-aksrt-forum.php b/inc/classes/class-aksrt-forum.php index 451e33f5..64211289 100644 --- a/inc/classes/class-aksrt-forum.php +++ b/inc/classes/class-aksrt-forum.php @@ -144,6 +144,13 @@ class AKSRT_Forum { return ['enabled' => false]; } + /** + * Alias kept for topic-service helpers that still call get_forum_config(). + */ + public function get_forum_config(): array { + return $this->get_config(); + } + public function get_boards(array $args = []): array { return $this->board_service()->get_boards($args); } @@ -345,6 +352,7 @@ class AKSRT_Forum { 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_unlock_topic', [$this, 'ajax_unlock_topic']); + add_action('wp_ajax_aksrt_forum_purchase_topic', [$this, 'ajax_purchase_topic']); // §7.4 draft persistence add_action('wp_ajax_aksrt_forum_save_draft', [$this, 'ajax_save_draft']); @@ -446,6 +454,26 @@ class AKSRT_Forum { $this->topic_service()->ajax_unlock_topic(); } + public function ajax_purchase_topic(): void { + $this->topic_service()->ajax_purchase_topic(); + } + + /** + * Lightweight result helper for topic service actions. + * + * @return array{success:bool,message:string} + */ + public function success(string $message = '', array $extra = []): array { + return array_merge(['success' => true, 'message' => $message], $extra); + } + + /** + * @return array{success:bool,message:string} + */ + public function error(string $message = '', array $extra = []): array { + return array_merge(['success' => false, 'message' => $message], $extra); + } + public function apply_moderator(int $user_id, int $board_id, string $reason) { return $this->moderation_service()->apply_moderator($user_id, $board_id, $reason); } diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php index 8f1b85d7..ea1f5d9c 100644 --- a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php @@ -188,7 +188,7 @@ trait AKSRT_Forum_Topic_Service_Part_3 { if ($viewer_user_id <= 0) { return false; } - return $this->has_user_unlocked_topic($viewer_user_id, (int) $post->ID); + return $this->has_user_paid_topic($viewer_user_id, (int) $post->ID); } if ($visibility === AKSRT_Forum::VISIBILITY_MODERATOR) { @@ -324,16 +324,12 @@ trait AKSRT_Forum_Topic_Service_Part_3 { 'topic_id' => $topic_id, ], sprintf(__('解锁帖子「%s」', 'aksrt'), get_the_title($topic_id))); - if (!$deducted) { - return $this->forum->error(__('积分扣除失败', 'aksrt')); + if (is_wp_error($deducted) || $deducted === false) { + $msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt'); + return $this->forum->error($msg ?: __('积分扣除失败', 'aksrt')); } - $unlocked = get_user_meta($user_id, 'aksrt_unlocked_topics', true); - if (!is_array($unlocked)) { - $unlocked = []; - } - $unlocked[] = $topic_id; - update_user_meta($user_id, 'aksrt_unlocked_topics', array_unique($unlocked)); + $this->mark_topic_unlocked($user_id, $topic_id); $author_id = (int) $post->post_author; if ($author_id > 0 && $author_id !== $user_id) { @@ -350,12 +346,211 @@ trait AKSRT_Forum_Topic_Service_Part_3 { return $this->forum->success(__('解锁成功', 'aksrt')); } + /** + * Cash/balance purchase for VISIBILITY_PAY topics. + * + * Reuses AKSRT_Order product_type=resource + balance credits conversion + * (1 CNY = 100 credits, same as AKSRT_Balance_Gateway). + * + * @return array{success:bool,message:string} + */ + public function purchase_topic_with_balance(int $user_id, int $topic_id): array { + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return $this->forum->error(__('主题不存在', 'aksrt')); + } + + if ($user_id <= 0) { + return $this->forum->error(__('请先登录', 'aksrt')); + } + + $visibility = (string) get_post_meta($topic_id, '_aksrt_visibility', true); + if ($visibility !== AKSRT_Forum::VISIBILITY_PAY) { + return $this->forum->error(__('该主题不是付费可见内容', 'aksrt')); + } + + if ($this->has_user_paid_topic($user_id, $topic_id)) { + return $this->forum->success(__('已购买', 'aksrt')); + } + + $price = $this->resolve_topic_pay_price($topic_id, $user_id); + if (!empty($price['is_free'])) { + $this->mark_topic_unlocked($user_id, $topic_id); + return $this->forum->success(__('VIP 免费解锁成功', 'aksrt'), [ + 'amount' => 0, + 'free' => true, + ]); + } + + $amount = (float) ($price['amount'] ?? 0); + if ($amount <= 0) { + return $this->forum->error(__('价格未设置', 'aksrt')); + } + + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Credits')) { + return $this->forum->error(__('支付系统不可用', 'aksrt')); + } + + $product_type = class_exists('AKSRT_Domain_Model') + ? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE + : 'resource'; + + $order_number = AKSRT_Order::get_instance()->create($user_id, $product_type, $topic_id, $amount, 'CNY'); + if ($order_number === '') { + return $this->forum->error(__('创建订单失败', 'aksrt')); + } + + // Align with AKSRT_Balance_Gateway: 1 CNY = 100 credits. + $credits_cost = (int) ceil($amount * 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($amount, 2, '.', ''), + $balance + )); + } + + $deducted = AKSRT_Credits::deduct( + $user_id, + $credits_cost, + 'forum_topic_pay', + [ + 'topic_id' => $topic_id, + 'order' => $order_number, + ], + sprintf(__('购买帖子「%s」', 'aksrt'), get_the_title($topic_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) { + // Best-effort refund so user is not charged without access. + AKSRT_Credits::add( + $user_id, + $credits_cost, + 'forum_topic_pay_refund', + ['topic_id' => $topic_id, 'order' => $order_number], + sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($topic_id)) + ); + return $this->forum->error(__('订单确认失败,已尝试退回积分', 'aksrt')); + } + + $this->mark_topic_unlocked($user_id, $topic_id); + + return $this->forum->success(__('购买成功', 'aksrt'), [ + 'order_number' => $order_number, + 'amount' => $amount, + 'credits_cost' => $credits_cost, + ]); + } + + /** + * Resolve cash price for a pay-visibility topic. + * + * @return array{amount:float,is_vip_price:bool,is_free:bool,label:string,base:float,vip_set:bool} + */ + public function resolve_topic_pay_price(int $topic_id, int $user_id = 0): array { + $base_raw = get_post_meta($topic_id, '_aksrt_pay_price', true); + $base = is_numeric($base_raw) ? (float) $base_raw : 0.0; + + $vip_raw = get_post_meta($topic_id, '_aksrt_pay_vip_price', true); + $vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false); + $vip_price = $vip_set && is_numeric($vip_raw) ? (float) $vip_raw : 0.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) { + return [ + 'amount' => max(0.0, $vip_price), + 'is_vip_price' => true, + 'is_free' => $vip_price <= 0, + 'label' => $vip_price <= 0 + ? __('VIP 免费', 'aksrt') + : sprintf(__('VIP ¥%s', 'aksrt'), number_format($vip_price, 2, '.', '')), + 'base' => $base, + 'vip_set' => true, + ]; + } + + return [ + 'amount' => max(0.0, $base), + 'is_vip_price' => false, + 'is_free' => false, + 'label' => $base > 0 + ? sprintf(__('¥%s', 'aksrt'), number_format($base, 2, '.', '')) + : __('价格未设置', 'aksrt'), + 'base' => $base, + 'vip_set' => $vip_set, + ]; + } + + public function mark_topic_unlocked(int $user_id, int $topic_id): void { + if ($user_id <= 0 || $topic_id <= 0) { + return; + } + $unlocked = get_user_meta($user_id, 'aksrt_unlocked_topics', true); + if (!is_array($unlocked)) { + $unlocked = []; + } + $unlocked[] = $topic_id; + update_user_meta($user_id, 'aksrt_unlocked_topics', array_values(array_unique(array_map('intval', $unlocked)))); + } + public function has_user_unlocked_topic(int $user_id, int $post_id): bool { $unlocked = get_user_meta($user_id, 'aksrt_unlocked_topics', true); if (!is_array($unlocked)) { return false; } - return in_array($post_id, $unlocked, true); + return in_array($post_id, array_map('intval', $unlocked), true); + } + + /** + * Paid access for VISIBILITY_PAY: unlock list OR paid resource order OR VIP free. + */ + public function has_user_paid_topic(int $user_id, int $topic_id): bool { + if ($user_id <= 0 || $topic_id <= 0) { + return false; + } + + if ($this->has_user_unlocked_topic($user_id, $topic_id)) { + return true; + } + + if (class_exists('AKSRT_Order')) { + $product_type = class_exists('AKSRT_Domain_Model') + ? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE + : 'resource'; + if (AKSRT_Order::get_instance()->has_paid_access($user_id, $product_type, $topic_id)) { + return true; + } + } + + $price = $this->resolve_topic_pay_price($topic_id, $user_id); + if (!empty($price['is_free'])) { + return true; + } + + return false; } public function has_user_commented_on(int $user_id, int $post_id): bool { diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php index be4d2a9e..20e7e491 100644 --- a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php @@ -263,4 +263,25 @@ trait AKSRT_Forum_Topic_Service_Part_4 { wp_send_json_error(['message' => $result['message'] ?? __('解锁失败', 'aksrt')]); } } + + public function ajax_purchase_topic(): void { + check_ajax_referer('aksrt_forum_action', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $topic_id = (int) ($_POST['topic_id'] ?? 0); + if ($topic_id <= 0) { + wp_send_json_error(['message' => __('主题不存在', 'aksrt')]); + } + + $result = $this->purchase_topic_with_balance($user_id, $topic_id); + if (!empty($result['success'])) { + wp_send_json_success($result); + } else { + wp_send_json_error(['message' => $result['message'] ?? __('购买失败', 'aksrt')]); + } + } } diff --git a/templates/forum/forum-topic.php b/templates/forum/forum-topic.php index 363a1595..c3fa4be6 100644 --- a/templates/forum/forum-topic.php +++ b/templates/forum/forum-topic.php @@ -89,9 +89,58 @@ if (empty($topic['can_view'])) : + 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($viewer_id); + if (!$is_vip_user && $viewer_id > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($viewer_id)) { + $is_vip_user = true; + } + $charge_amount = (float) $pay_price; + $is_free_vip = false; + if ($is_vip_user && $vip_set) { + $charge_amount = max(0.0, (float) $vip_price_raw); + $is_free_vip = $charge_amount <= 0; + } + ?>

- - + + + $charge_amount) { + echo ' ' . esc_html(sprintf(__('原价 ¥%s', 'aksrt'), number_format((float) $pay_price, 2, '.', ''))) . ''; + } + } + ?> + + + + + + + + +