diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css index 4f4890eb..c36e4126 100644 --- a/assets/css/routes/user-center/credits.css +++ b/assets/css/routes/user-center/credits.css @@ -1,27 +1,285 @@ /** - * User Center — credits balance + transactions + * User Center — assets (wallet + points) Zibll parity */ .uc-balance-card { box-shadow: none; } -/* Dual assets: wallet + points cards stack with gap */ -.uc-wallet-grid { +/* Dual asset row */ +.uc-assets-row { + display: grid; + grid-template-columns: 1fr; + gap: 12px; margin-bottom: 16px; } - -.uc-balance-card--wallet .uc-balance-card__bg--wallet, -.uc-balance-card__bg--wallet { - background: linear-gradient(135deg, #0f766e 0%, #14b8a6 45%, #2dd4bf 100%); +.uc-assets-row--dual { + grid-template-columns: 1fr 1fr; +} +@media (max-width: 767px) { + .uc-assets-row--dual { + grid-template-columns: 1fr; + } } -.uc-balance-card + .uc-balance-card { - margin-top: 12px; +.uc-asset-card { + background: var(--main-bg-color, var(--surface-1, #fff)); + border-radius: var(--main-radius, 8px); + box-shadow: 0 0 10px var(--main-shadow, rgba(116, 116, 116, 0.08)); + padding: 16px 20px; + margin-bottom: 0; +} +.uc-asset-card__head { + margin-bottom: 8px; +} +.uc-asset-card__title { + font-weight: 600; + color: var(--main-color, #4e5358); +} +.uc-asset-card__amount { + font-size: 1.75rem; + line-height: 1.2; +} +.uc-asset-card__mark { + font-size: 12px; + margin-right: 2px; +} +.uc-asset-card__actions .but { + min-width: 4.2em; +} +.uc-asset-card__link { + display: inline-flex; + align-items: center; + margin-top: 8px; + background: none; + border: 0; + padding: 0; + cursor: pointer; +} +.uc-asset-card__link:hover { + color: var(--focus-color, #f04494); +} + +/* Sidebar mini dual cards (Zibll) */ +.uc-asset-mini { + gap: 8px; +} +.uc-asset-mini__card { + display: block; + background: var(--muted-bg-color, #f3f4f6); + border-radius: 6px; + padding: 10px 12px; + text-decoration: none; + color: inherit; + transition: background 0.15s; +} +.uc-asset-mini__card:hover { + background: var(--color-line-soft, rgba(0, 0, 0, 0.06)); +} +.uc-asset-mini__label { + margin-bottom: 6px; +} +.c-blue-2 { + color: #3b82f6; +} +.c-yellow { + color: #eab308; +} +.c-green { + color: #16a34a; +} +.col-ml6 { + margin-left: -6px; +} +.col-ml6 > * { + margin-left: 6px; +} + +/* Charge packages */ +.uc-charge-packages { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 8px; +} +.uc-charge-package { + position: relative; + display: block; + padding: 12px; + border: 1px solid var(--color-line, rgba(0, 0, 0, 0.08)); + border-radius: 8px; + cursor: pointer; + background: var(--surface-1, #fff); +} +.uc-charge-package input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.uc-charge-package.is-active, +.uc-charge-package:has(input:checked) { + border-color: var(--focus-color, #f04494); + box-shadow: 0 0 0 1px var(--focus-color, #f04494); +} +.uc-charge-package__credit { + font-size: 1.25rem; + font-weight: 700; +} +.uc-charge-package__tag { + position: absolute; + top: 0; + right: 0; + font-size: 11px; + padding: 2px 8px; + border-radius: 0 8px 0 8px; + background: #fbbf24; + color: #333; +} +.uc-charge-custom__input { + flex: 1; + min-width: 0; + border: 0; + border-bottom: 1px solid var(--color-line, #ddd); + padding: 6px 0; + font-size: 1.1rem; + outline: none; +} +.uc-charge-methods { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.uc-charge-method { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +/* Modal shell */ +.uc-modal { + position: fixed; + inset: 0; + z-index: 1100; + display: none; + align-items: center; + justify-content: center; + padding: 16px; +} +.uc-modal.is-open, +.uc-modal:not([hidden]) { + display: flex; +} +.uc-modal[hidden] { + display: none !important; +} +.uc-modal__overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); +} +.uc-modal__dialog { + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; + max-height: 85vh; + overflow: auto; + background: var(--main-bg-color, #fff); + border-radius: 12px; + padding: 18px 18px 20px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); +} +.uc-modal__close { + position: absolute; + top: 10px; + right: 12px; + border: 0; + background: transparent; + font-size: 18px; + cursor: pointer; + color: #999; +} +.uc-modal__title { + margin: 0 0 14px; + font-size: 17px; + font-weight: 700; +} +.uc-modal__desc { + margin-bottom: 12px; +} +.uc-modal__tip { + min-height: 1.4em; + margin: 10px 0 0; + font-size: 13px; + color: var(--muted-color, #777); +} +.uc-modal__tip.is-error { + color: #ef4444; +} +body.uc-modal-open { + overflow: hidden; +} +.btn-block { + display: block; + width: 100%; +} +.but.jb-blue, +.but.b-blue-2 { + background: #3b82f6; + color: #fff; + border: 0; + border-radius: 4px; + padding: 0.45em 1em; + cursor: pointer; +} +.but.c-blue-2.hollow, +.but.c-green.hollow { + background: transparent; + border: 1px solid currentColor; + border-radius: 4px; + padding: 0.4em 0.9em; + cursor: pointer; +} +.but.c-blue-2 { + color: #3b82f6; +} +.but.c-green { + color: #16a34a; +} + +/* Transfer search results */ +.uc-transfer-results { + margin-top: 8px; + max-height: 200px; + overflow: auto; +} +.uc-transfer-user { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: 0; + background: var(--muted-bg-color, #f5f5f5); + border-radius: 6px; + margin-bottom: 6px; + cursor: pointer; + text-align: left; +} +.uc-transfer-user:hover { + background: var(--color-primary-soft, rgba(240, 68, 148, 0.08)); +} +.uc-transfer-user__avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; } .uc-transactions--wallet { margin-bottom: 20px; + margin-top: 12px; } .uc-content #panel-credits .uc-transactions { diff --git a/assets/js/credits.js b/assets/js/credits.js index 40a4d3ed..46ba85e7 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -515,19 +515,280 @@ }); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { - initSignin(); - initInvite(); - initVipShop(); - initVipPointsExchange(); - initCardPass(); + // ===== 余额充值 / 转账 / 积分兑余额(Zibll 资产中心) ===== + function openModal(el) { + if (!el) return; + el.hidden = false; + el.classList.add('is-open'); + document.body.classList.add('uc-modal-open'); + } + function closeModal(el) { + if (!el) return; + el.hidden = true; + el.classList.remove('is-open'); + if (!document.querySelector('.uc-modal.is-open')) { + document.body.classList.remove('uc-modal-open'); + } + } + function pollPaidOrder(orderNumber) { + if (!orderNumber) return; + var tries = 0; + var maxTries = 40; + var timer = setInterval(function () { + tries += 1; + api('/pay/content/order/' + encodeURIComponent(orderNumber)).then(function (r) { + if (r.ok && r.json && r.json.paid) { + clearInterval(timer); + window.location.reload(); + } + }).catch(function () {}); + if (tries >= maxTries) clearInterval(timer); + }, 3000); + } + + function initBalanceCharge() { + var modal = document.querySelector('[data-aksrt-charge-modal]'); + if (!modal) return; + var form = modal.querySelector('[data-aksrt-charge-form]'); + var tip = modal.querySelector('[data-aksrt-charge-tip]'); + var busy = false; + function setTip(text, isError) { + if (!tip) return; + tip.textContent = text || ''; + tip.classList.toggle('is-error', !!isError); + } + document.querySelectorAll('[data-aksrt-charge-open]').forEach(function (btn) { + btn.addEventListener('click', function () { openModal(modal); }); }); - } else { + modal.querySelectorAll('[data-aksrt-charge-close]').forEach(function (btn) { + btn.addEventListener('click', function () { closeModal(modal); }); + }); + modal.querySelectorAll('.uc-charge-package input').forEach(function (inp) { + inp.addEventListener('change', function () { + modal.querySelectorAll('.uc-charge-package').forEach(function (el) { + el.classList.toggle('is-active', el.contains(inp) && inp.checked); + }); + }); + }); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (busy) return; + var pkgInput = form.querySelector('input[name="balance_package"]:checked') || form.querySelector('input[name="balance_package"]'); + var pkg = pkgInput ? pkgInput.value : 'custom'; + var customEl = form.querySelector('input[name="custom_price"]'); + var custom = customEl ? parseFloat(customEl.value || '0') : 0; + if (custom > 0) { + pkg = 'custom'; + } + if ((!pkg || pkg === 'custom') && !(custom > 0) && !pkgInput) { + setTip('请选择或输入充值金额', true); + return; + } + if (pkg === 'custom' && !(custom > 0)) { + setTip('请输入有效充值金额', true); + return; + } + var methodEl = form.querySelector('input[name="payment_method"]:checked'); + var method = methodEl ? methodEl.value : 'alipay'; + busy = true; + setTip('正在创建订单…'); + api('/pay/balance/charge', { + method: 'POST', + body: JSON.stringify({ + package: pkg, + custom_price: custom, + payment_method: method + }) + }).then(function (r) { + busy = false; + if (!r.ok || (r.json && r.json.code && !r.json.success)) { + setTip((r.json && r.json.message) || '充值失败', true); + return; + } + var data = (r.json && r.json.data) ? r.json.data : r.json; + if (data && data.redirect && (data.payment_url || data.qr_content)) { + if (data.payment_url) window.open(data.payment_url, '_blank'); + setTip(data.message || '请完成支付,支付成功后将自动刷新。'); + if (data.order_number) pollPaidOrder(data.order_number); + return; + } + setTip((data && data.message) || '充值成功,正在刷新…'); + window.location.reload(); + }).catch(function () { + busy = false; + setTip('网络错误,请重试', true); + }); + }); + } + + function initTransfer() { + var modal = document.querySelector('[data-aksrt-transfer-modal]'); + if (!modal) return; + var form = modal.querySelector('[data-aksrt-transfer-form]'); + var tip = modal.querySelector('[data-aksrt-transfer-tip]'); + var results = modal.querySelector('[data-aksrt-transfer-results]'); + var selected = modal.querySelector('[data-aksrt-transfer-selected]'); + var recipientInput = modal.querySelector('[data-aksrt-transfer-recipient]'); + var typeInput = modal.querySelector('[data-aksrt-transfer-type]'); + var title = modal.querySelector('[data-aksrt-transfer-title]'); + var searchTimer = null; + var busy = false; + function setTip(text, isError) { + if (!tip) return; + tip.textContent = text || ''; + tip.classList.toggle('is-error', !!isError); + } + document.querySelectorAll('[data-aksrt-transfer-open]').forEach(function (btn) { + btn.addEventListener('click', function () { + var type = btn.getAttribute('data-aksrt-transfer-open') || 'balance'; + if (typeInput) typeInput.value = type; + if (title) title.textContent = type === 'points' ? '积分转账' : '余额转账'; + if (results) results.innerHTML = ''; + if (selected) selected.textContent = ''; + if (recipientInput) recipientInput.value = ''; + setTip(''); + openModal(modal); + }); + }); + modal.querySelectorAll('[data-aksrt-transfer-close]').forEach(function (btn) { + btn.addEventListener('click', function () { closeModal(modal); }); + }); + var searchEl = modal.querySelector('[data-aksrt-transfer-search]'); + if (searchEl) { + searchEl.addEventListener('input', function () { + var q = String(searchEl.value || '').trim(); + if (searchTimer) clearTimeout(searchTimer); + if (q.length < 1) { + if (results) results.innerHTML = ''; + return; + } + searchTimer = setTimeout(function () { + api('/pay/transfer/search?s=' + encodeURIComponent(q)).then(function (r) { + if (!results) return; + results.innerHTML = ''; + if (!r.ok) return; + var data = (r.json && r.json.data) ? r.json.data : r.json; + var users = (data && data.users) || []; + if (!users.length) { + results.innerHTML = '
未找到相关用户
'; + return; + } + users.forEach(function (u) { + var item = document.createElement('button'); + item.type = 'button'; + item.className = 'uc-transfer-user'; + item.innerHTML = '' + (u.display_name || u.user_login) + '@' + (u.user_login || '') + ''; + item.addEventListener('click', function () { + if (recipientInput) recipientInput.value = String(u.id); + if (selected) selected.textContent = '已选择:' + (u.display_name || u.user_login); + results.innerHTML = ''; + }); + results.appendChild(item); + }); + }); + }, 280); + }); + } + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (busy) return; + var type = typeInput ? typeInput.value : 'balance'; + var recipient = recipientInput ? parseInt(recipientInput.value || '0', 10) : 0; + var amountEl = form.querySelector('[data-aksrt-transfer-amount]'); + var amount = amountEl ? amountEl.value : 0; + if (!recipient) { + setTip('请选择收款用户', true); + return; + } + if (!(parseFloat(amount) > 0)) { + setTip('请输入有效金额', true); + return; + } + busy = true; + setTip('正在转账…'); + api('/pay/transfer', { + method: 'POST', + body: JSON.stringify({ type: type, recipient: recipient, amount: amount }) + }).then(function (r) { + busy = false; + if (!r.ok || (r.json && r.json.code && !r.json.success)) { + setTip((r.json && r.json.message) || '转账失败', true); + return; + } + var data = (r.json && r.json.data) ? r.json.data : r.json; + setTip((data && data.message) || '转账成功,正在刷新…'); + window.location.reload(); + }).catch(function () { + busy = false; + setTip('网络错误,请重试', true); + }); + }); + } + + function initExchange() { + var modal = document.querySelector('[data-aksrt-exchange-modal]'); + if (!modal) return; + var form = modal.querySelector('[data-aksrt-exchange-form]'); + var tip = modal.querySelector('[data-aksrt-exchange-tip]'); + var busy = false; + function setTip(text, isError) { + if (!tip) return; + tip.textContent = text || ''; + tip.classList.toggle('is-error', !!isError); + } + document.querySelectorAll('[data-aksrt-exchange-open]').forEach(function (btn) { + btn.addEventListener('click', function () { openModal(modal); }); + }); + modal.querySelectorAll('[data-aksrt-exchange-close]').forEach(function (btn) { + btn.addEventListener('click', function () { closeModal(modal); }); + }); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (busy) return; + var pointsEl = form.querySelector('[data-aksrt-exchange-points]'); + var points = pointsEl ? parseInt(pointsEl.value || '0', 10) : 0; + if (!(points > 0)) { + setTip('请输入兑换积分数量', true); + return; + } + busy = true; + setTip('正在兑换…'); + api('/pay/balance/exchange', { + method: 'POST', + body: JSON.stringify({ points: points }) + }).then(function (r) { + busy = false; + if (!r.ok || (r.json && r.json.code && !r.json.success)) { + setTip((r.json && r.json.message) || '兑换失败', true); + return; + } + var data = (r.json && r.json.data) ? r.json.data : r.json; + setTip((data && data.message) || '兑换成功,正在刷新…'); + window.location.reload(); + }).catch(function () { + busy = false; + setTip('网络错误,请重试', true); + }); + }); + } + + function bootAssets() { initSignin(); initInvite(); initVipShop(); initVipPointsExchange(); initCardPass(); + initBalanceCharge(); + initTransfer(); + initExchange(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootAssets); + } else { + bootAssets(); } })(); diff --git a/functions.php b/functions.php index 8ae4fd4c..c1b8e16f 100644 --- a/functions.php +++ b/functions.php @@ -189,6 +189,9 @@ if (class_exists('AKSRT_Vip_Service')) { if (class_exists('AKSRT_Charge_Card')) { AKSRT_Charge_Card::get_instance()->init(); } +if (class_exists('AKSRT_Balance_Service')) { + AKSRT_Balance_Service::get_instance()->init(); +} // Download State & Docs Presenter are pure service singletons (no hooks) if (class_exists('AKSRT_Download_State')) { AKSRT_Download_State::get_instance(); diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 2381d0f5..f74a3a50 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -226,6 +226,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-db-migrator.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-credits.php'; // 站内现金钱包(与积分分离) require_once AKSRT_DIR . '/inc/classes/class-aksrt-balance.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-balance-service.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-credits-earn.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-signin.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-invite.php'; diff --git a/inc/classes/class-aksrt-balance-service.php b/inc/classes/class-aksrt-balance-service.php new file mode 100644 index 00000000..3e6d0a3d --- /dev/null +++ b/inc/classes/class-aksrt-balance-service.php @@ -0,0 +1,609 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_charge_options'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_charge_create'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/transfer', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_transfer'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/transfer/search', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_transfer_search'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/balance/exchange', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_exchange_from_credits'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + } + + /** + * GET charge options (packages + methods + limits). + */ + public function rest_charge_options(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $user_id = get_current_user_id(); + return rest_ensure_response([ + 'success' => true, + 'data' => $this->get_charge_view($user_id), + ]); + } + + /** + * POST create recharge order and initiate payment. + */ + public function rest_charge_create(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $user_id = get_current_user_id(); + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); + $custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0; + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); + + $result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method); + if (is_wp_error($result)) { + return $result; + } + return rest_ensure_response(['success' => true, 'data' => $result]); + } + + /** + * Transfer balance or points between users. + */ + public function rest_transfer(WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $type = sanitize_key((string) ($params['type'] ?? 'balance')); + $recipient = (int) ($params['recipient'] ?? 0); + $amount_raw = $params['amount'] ?? $params['price'] ?? 0; + $from_id = get_current_user_id(); + + if (!in_array($type, ['balance', 'points'], true)) { + return new WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]); + } + if ($from_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if ($recipient <= 0 || $recipient === $from_id) { + return new WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]); + } + if (!get_userdata($recipient)) { + return new WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]); + } + + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + if ($type === 'balance') { + if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) { + return new WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]); + } + $amount = round((float) $amount_raw, 2); + if ($amount <= 0) { + return new WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]); + } + if (!class_exists('AKSRT_Balance')) { + return new WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]); + } + $from_bal = AKSRT_Balance::balance($from_id); + if ($from_bal < $amount) { + return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); + } + $fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0))); + $credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; + + $from_user = wp_get_current_user(); + $to_user = get_userdata($recipient); + $deduct = AKSRT_Balance::deduct( + $from_id, + $amount, + 'transfer_out', + ['type' => 'user', 'id' => $recipient], + sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + AKSRT_Balance::add( + $recipient, + $credit, + 'transfer_in', + ['type' => 'user', 'id' => $from_id], + sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) + ); + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('转账成功', 'aksrt'), + 'reload' => true, + 'balance' => AKSRT_Balance::balance($from_id), + ], + ]); + } + + // points transfer + $points_transfer_on = !empty(AKSRT_Settings::get_all()['points_transfer_s']); + if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled() || !$points_transfer_on) { + return new WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]); + } + $amount = (int) $amount_raw; + if ($amount <= 0) { + return new WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]); + } + if (!class_exists('AKSRT_Credits')) { + return new WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); + } + $fee_rate = max(0, min(50, (int) (AKSRT_Settings::get_all()['points_service_charge'] ?? 0))); + $credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount; + $from_user = wp_get_current_user(); + $to_user = get_userdata($recipient); + $deduct = AKSRT_Credits::deduct( + $from_id, + $amount, + 'transfer_out', + ['type' => 'user', 'id' => $recipient], + sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + AKSRT_Credits::add( + $recipient, + $credit, + 'transfer_in', + ['type' => 'user', 'id' => $from_id], + sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) + ); + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('转账成功', 'aksrt'), + 'reload' => true, + 'balance' => AKSRT_Credits::balance($from_id), + ], + ]); + } + + public function rest_transfer_search(WP_REST_Request $request) { + $s = sanitize_text_field((string) $request->get_param('s')); + if (mb_strlen($s) < 1) { + return rest_ensure_response(['success' => true, 'data' => ['users' => []]]); + } + $exclude = [get_current_user_id()]; + $q = new WP_User_Query([ + 'search' => '*' . $s . '*', + 'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'], + 'exclude' => $exclude, + 'number' => 20, + 'fields' => ['ID', 'display_name', 'user_login'], + ]); + $users = []; + foreach ((array) $q->get_results() as $u) { + $users[] = [ + 'id' => (int) $u->ID, + 'display_name' => (string) $u->display_name, + 'user_login' => (string) $u->user_login, + 'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]), + ]; + } + return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]); + } + + /** + * Exchange points → cash wallet (pay_balance_exchange_*). + */ + public function rest_exchange_from_credits(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + if (empty($cfg['exchange']['enabled'])) { + return new WP_Error('aksrt_exchange_closed', __('积分兑换余额未开启', 'aksrt'), ['status' => 403]); + } + if (!class_exists('AKSRT_Credits') || !AKSRT_Settings::credits_enabled()) { + return new WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $points = (int) ($params['points'] ?? 0); + $rate = max(1, (int) ($cfg['exchange']['rate'] ?? 100)); // N points = 1 yuan + if ($points < $rate) { + return new WP_Error('aksrt_exchange_min', sprintf(__('最少兑换 %d 积分', 'aksrt'), $rate), ['status' => 400]); + } + // Floor to whole yuan. + $yuan = (int) floor($points / $rate); + $cost = $yuan * $rate; + if ($yuan <= 0 || $cost <= 0) { + return new WP_Error('aksrt_exchange_amount', __('兑换数量无效', 'aksrt'), ['status' => 400]); + } + $user_id = get_current_user_id(); + $deduct = AKSRT_Credits::deduct( + $user_id, + $cost, + 'exchange_to_balance', + ['type' => 'balance'], + sprintf(__('积分兑换余额 %d 元', 'aksrt'), $yuan) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + $add = AKSRT_Balance::add( + $user_id, + (float) $yuan, + 'exchange_from_credits', + ['type' => 'credits'], + sprintf(__('积分兑换:消耗 %d 积分', 'aksrt'), $cost) + ); + if (is_wp_error($add)) { + // best-effort refund points + AKSRT_Credits::add($user_id, $cost, 'exchange_refund', [], __('兑换失败退回积分', 'aksrt')); + return $add; + } + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => sprintf(__('兑换成功:+%d 元', 'aksrt'), $yuan), + 'wallet_balance' => AKSRT_Balance::balance($user_id), + 'credit_balance' => AKSRT_Credits::balance($user_id), + 'reload' => true, + ], + ]); + } + + /** + * When a recharge order is paid, credit the wallet. + * + * @param string $order_number Order number. + * @param array $order Order row. + */ + public function on_order_paid(string $order_number, array $order = []): void { + if ($order === [] && class_exists('AKSRT_Order_Repository')) { + $order = AKSRT_Order_Repository::get_instance()->get($order_number) ?: []; + } + if (!$order) { + return; + } + $type = sanitize_key((string) ($order['product_type'] ?? '')); + if ($type !== AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE) { + return; + } + $user_id = (int) ($order['user_id'] ?? 0); + if ($user_id <= 0 || !class_exists('AKSRT_Balance')) { + return; + } + // Prevent double credit. + $note = json_decode((string) ($order['note'] ?? ''), true); + if (!is_array($note)) { + $note = []; + } + if (!empty($note['balance_credited'])) { + return; + } + $credit_amount = isset($note['credit_amount']) + ? round((float) $note['credit_amount'], 2) + : round((float) ($order['amount'] ?? 0), 2); + if ($credit_amount <= 0) { + return; + } + $result = AKSRT_Balance::add( + $user_id, + $credit_amount, + 'recharge', + ['type' => 'order', 'order' => $order_number], + sprintf(__('余额充值订单 %s', 'aksrt'), $order_number) + ); + if (is_wp_error($result)) { + return; + } + $note['balance_credited'] = 1; + $note['credited_amount'] = $credit_amount; + // Mark credited in note (best-effort; private table accessor). + global $wpdb; + $tbl = $wpdb->prefix . 'aksrt_order'; + $wpdb->update( + $tbl, + ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], + ['order_number' => $order_number], + ['%s'], + ['%s'] + ); + } + + /** + * @return array + */ + public function get_charge_view(int $user_id): array { + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + $packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? '')); + $custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']); + $custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999)); + $methods = []; + if (function_exists('aksrt_list_cash_payment_methods')) { + // Exclude balance gateway for recharge (cannot pay wallet with wallet). + foreach (aksrt_list_cash_payment_methods(true) as $m) { + $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; + if ($id === '' || $id === 'balance') { + continue; + } + $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; + } + } + return [ + 'enabled' => !empty($cfg['enabled']), + 'desc' => (string) ($cfg['desc'] ?? ''), + 'wallet' => class_exists('AKSRT_Balance') ? AKSRT_Balance::balance($user_id) : 0.0, + 'packages' => $packages, + 'custom_s' => $custom_s, + 'custom_limit' => $custom_limit, + 'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'), + 'payment_methods' => $methods, + 'transfer' => [ + 'enabled' => !empty($cfg['transfer']['enabled']), + 'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0), + 'desc' => (string) ($cfg['transfer']['desc'] ?? ''), + ], + 'exchange' => [ + 'enabled' => !empty($cfg['exchange']['enabled']), + 'rate' => (int) ($cfg['exchange']['rate'] ?? 100), + 'desc' => (string) ($cfg['exchange']['desc'] ?? ''), + ], + 'pass_charge' => is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [], + ]; + } + + /** + * @return array + */ + public function parse_packages(string $raw): array { + $out = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $i = 0; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + // pay|credit|title OR credit|pay|title — Zibll uses price(credit)|pay_price|tag + // Admin help: 支付金额|到账金额|标题 + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + continue; + } + $pay = (float) $parts[0]; + $credit = (float) $parts[1]; + $title = $parts[2] ?? ''; + if ($pay <= 0 || $credit <= 0) { + continue; + } + $out[] = [ + 'key' => (string) $i, + 'pay_price' => round($pay, 2), + 'price' => round($credit, 2), + 'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit), + 'tag' => $title, + ]; + $i++; + } + return $out; + } + + /** + * @return array|WP_Error + */ + public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { + return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); + } + $view = $this->get_charge_view($user_id); + $credit_amount = 0.0; + $pay_amount = 0.0; + $title = __('余额充值', 'aksrt'); + + if ($package_key === 'custom' || $package_key === '') { + if (empty($view['custom_s'])) { + return new WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]); + } + $custom_price = round($custom_price, 2); + $min = 1.0; + $max = (float) $view['custom_limit']; + if ($custom_price < $min || $custom_price > $max) { + return new WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]); + } + $pay_amount = $custom_price; + $credit_amount = $custom_price; + $title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price); + $package_key = 'custom'; + } else { + $found = null; + foreach ((array) $view['packages'] as $pkg) { + if ((string) ($pkg['key'] ?? '') === (string) $package_key) { + $found = $pkg; + break; + } + } + if (!$found) { + return new WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]); + } + $pay_amount = (float) $found['pay_price']; + $credit_amount = (float) $found['price']; + $title = (string) $found['title']; + } + + if ($pay_amount <= 0 || $credit_amount <= 0) { + return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); + } + + $payment_method = sanitize_key($payment_method); + if ($payment_method === '' || $payment_method === 'balance') { + // Prefer first non-balance method. + $methods = $view['payment_methods'] ?? []; + $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; + } + + $note = wp_json_encode([ + 'balance_recharge' => 1, + 'package' => $package_key, + 'credit_amount' => $credit_amount, + 'pay_amount' => $pay_amount, + 'title' => $title, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $order_number = AKSRT_Order_Repository::get_instance()->create([ + 'user_id' => $user_id, + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE, + 'product_id' => 0, + 'amount' => $pay_amount, + 'currency' => 'CNY', + 'status' => AKSRT_Order::STATUS_PENDING, + 'payment_method' => $payment_method, + 'note' => is_string($note) ? $note : '', + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + ]); + if ($order_number === '') { + return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); + } + + $order = AKSRT_Order_Repository::get_instance()->get($order_number); + if (!$order) { + return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); + } + $order['subject'] = $title; + $order['payment_method'] = $payment_method; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); + } + $gateway = AKSRT_Payment_Gateway::get_instance()->get_gateway($payment_method); + if (!$gateway) { + return new WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用', 'aksrt'), [ + 'status' => 400, + 'order_number' => $order_number, + ]); + } + if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) { + return new WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [ + 'status' => 400, + 'order_number' => $order_number, + ]); + } + + $payment_result = $gateway->create_payment($order); + if (empty($payment_result['success'])) { + return new WP_Error( + 'aksrt_pay_failed', + (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), + ['status' => 402, 'order_number' => $order_number] + ); + } + + $code_url = (string) ($payment_result['payment_data']['code_url'] ?? ''); + $payment_url = (string) ($payment_result['payment_url'] ?? ''); + if ($payment_url !== '' || $code_url !== '') { + return [ + 'redirect' => true, + 'payment_url' => $payment_url, + 'qr_content' => $code_url, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'credit_amount' => $credit_amount, + 'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'), + 'reload' => false, + 'poll' => true, + ]; + } + + // Synchronous gateway (rare for recharge): mark paid → on_order_paid credits wallet. + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => $payment_method, + 'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')), + 'note' => __('余额充值支付完成', 'aksrt'), + ]); + if (!$paid) { + return new WP_Error('aksrt_order_confirm_failed', __('支付确认失败', 'aksrt'), ['status' => 500]); + } + // Ensure credit even if status hook shape differs. + $this->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); + + return [ + 'redirect' => false, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'credit_amount' => $credit_amount, + 'wallet' => AKSRT_Balance::balance($user_id), + 'message' => __('充值成功', 'aksrt'), + 'reload' => true, + ]; + } +} diff --git a/inc/helpers/user-center-renderers/credits-balance.php b/inc/helpers/user-center-renderers/credits-balance.php index 60ff07f2..b4c21a75 100644 --- a/inc/helpers/user-center-renderers/credits-balance.php +++ b/inc/helpers/user-center-renderers/credits-balance.php @@ -1,8 +1,6 @@ get_public_options(); $card_pass_credits = is_array($opts['credits'] ?? null) ? $opts['credits'] : $card_pass_credits; } +$points_transfer_on = class_exists('AKSRT_Settings') && !empty(AKSRT_Settings::get_all()['points_transfer_s']); +$currency = (string) ($currency_name ?? __('积分', 'aksrt')); ?> -
-
-
-
- - +
+
+
+
-
- -
-
- - -
-

+ +
+
+
+ +
+
+ + + +
+
+

diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index 4512c0b8..c831e143 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -1,8 +1,8 @@ false, 'desc' => '', 'only_password' => true]; if (class_exists('AKSRT_Charge_Card')) { $opts = AKSRT_Charge_Card::get_instance()->get_public_options(); $card_pass_balance = is_array($opts['balance'] ?? null) ? $opts['balance'] : $card_pass_balance; -} elseif (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) { - $recharge = AKSRT_Settings::get_payment_recharge_settings(); - $pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : []; - $card_pass_balance = [ - 'enabled' => !empty($pc['enabled']), - 'desc' => (string) ($pc['desc'] ?? ''), - 'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']), - ]; +} elseif (!empty($recharge['pass_charge'])) { + $card_pass_balance = is_array($recharge['pass_charge']) ? $recharge['pass_charge'] : $card_pass_balance; } + +$charge_view = []; +if (class_exists('AKSRT_Balance_Service')) { + $charge_view = AKSRT_Balance_Service::get_instance()->get_charge_view((int) $user_id); +} +$packages = is_array($charge_view['packages'] ?? null) ? $charge_view['packages'] : []; +$methods = is_array($charge_view['payment_methods'] ?? null) ? $charge_view['payment_methods'] : []; +$custom_s = !empty($charge_view['custom_s']); +$custom_limit = (int) ($charge_view['custom_limit'] ?? 9999); +$mark = (string) ($charge_view['currency_mark'] ?? '元'); ?> -
-
-
-
-
- - -
-
- - - -
-

+
+
+
+
+
+
+ + +
+
+ + + + +
+
+ + +
@@ -73,20 +90,128 @@ if (class_exists('AKSRT_Charge_Card')) {
+ + + + + + + + + + +

- +

(int) $user_id, 'page' => $wallet_page, 'per_page' => 10]); - } else { - $wlog = ['items' => [], 'total' => 0]; - } + $wlog = class_exists('AKSRT_Balance') + ? AKSRT_Balance::get_log(['user_id' => (int) $user_id, 'page' => $wallet_page, 'per_page' => 10]) + : ['items' => [], 'total' => 0]; if (!empty($wlog['items'])) : ?>
@@ -101,35 +226,20 @@ if (class_exists('AKSRT_Charge_Card')) {
- + ()
-
- -
+
- - - + @@ -145,9 +255,7 @@ if (class_exists('AKSRT_Charge_Card')) { ?>
-
- -
+

diff --git a/inc/helpers/user-center-renderers/profile-credits.php b/inc/helpers/user-center-renderers/profile-credits.php index a672a239..6d1d0460 100644 --- a/inc/helpers/user-center-renderers/profile-credits.php +++ b/inc/helpers/user-center-renderers/profile-credits.php @@ -1,8 +1,8 @@ +
+ +
+ +
+ + +
+ +
+ +
+ diff --git a/inc/helpers/user-center-renderers/sidebar-commerce.php b/inc/helpers/user-center-renderers/sidebar-commerce.php index 2b5987bf..f2105281 100644 --- a/inc/helpers/user-center-renderers/sidebar-commerce.php +++ b/inc/helpers/user-center-renderers/sidebar-commerce.php @@ -1,44 +1,71 @@ +
+
+ +
+
- - + + - - + + - - - - - - - - + +