Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
9bf079b7
Unverified
Commit
9bf079b7
authored
Apr 15, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 15, 2026
Browse files
Merge pull request #1655 from touwaeriol/feat/payment-fee-multiplier
feat(payment): balance recharge multiplier and fee rate
parents
7c671b53
c2108421
Changes
28
Show whitespace changes
Inline
Side-by-side
frontend/src/components/payment/ToggleSwitch.vue
View file @
9bf079b7
<
template
>
<label
class=
"flex items-center gap-
1
.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
label
}}
</span>
<label
class=
"flex
flex-col
items-center gap-
0
.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400
whitespace-nowrap
"
>
{{
label
}}
</span>
<button
type=
"button"
role=
"switch"
...
...
frontend/src/i18n/locales/en.ts
View file @
9bf079b7
...
...
@@ -4566,6 +4566,12 @@ export default {
minAmount
:
'
Minimum Amount
'
,
maxAmount
:
'
Maximum Amount
'
,
dailyLimit
:
'
Daily Limit
'
,
balanceRechargeMultiplier
:
'
Balance Recharge Multiplier
'
,
balanceRechargeMultiplierHint
:
'
How many USD balance the user receives for each 1 CNY paid
'
,
balanceRechargePreview
:
'
Preview: 1 CNY = {usd} USD
'
,
rechargeFeeRate
:
'
Recharge Fee Rate
'
,
rechargeFeeRateHint
:
'
Percentage of service fee charged on top of recharge amount, 0 means no fee
'
,
rechargeFeePreview
:
'
Preview: Recharge 100, fee {fee}
'
,
orderTimeout
:
'
Order Timeout
'
,
orderTimeoutHint
:
'
In minutes, minimum 1
'
,
maxPendingOrders
:
'
Max Pending Orders
'
,
...
...
@@ -5324,6 +5330,8 @@ export default {
payment
:
{
title
:
'
Recharge / Subscription
'
,
amountLabel
:
'
Amount
'
,
paymentAmount
:
'
Payment Amount
'
,
creditedBalance
:
'
Credited Balance
'
,
quickAmounts
:
'
Quick Amounts
'
,
customAmount
:
'
Custom Amount
'
,
enterAmount
:
'
Enter amount
'
,
...
...
@@ -5379,6 +5387,10 @@ export default {
orderNo
:
'
Order No.
'
,
amount
:
'
Amount
'
,
payAmount
:
'
Paid
'
,
creditedAmount
:
'
Credited Amount
'
,
fee
:
'
Fee
'
,
baseAmount
:
'
Base Amount
'
,
includedInPayAmount
:
'
included in paid amount
'
,
status
:
'
Status
'
,
paymentMethod
:
'
Payment Method
'
,
createdAt
:
'
Created
'
,
...
...
@@ -5408,6 +5420,7 @@ export default {
amountTooLow
:
'
Minimum amount is {min}
'
,
amountTooHigh
:
'
Maximum amount is {max}
'
,
amountNoMethod
:
'
No payment method available for this amount
'
,
rechargeRatePreview
:
'
Current rate: 1 CNY = {usd} USD
'
,
refundReason
:
'
Refund Reason
'
,
refundReasonPlaceholder
:
'
Please describe your refund reason
'
,
stripeLoadFailed
:
'
Failed to load payment component. Please refresh and try again.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
9bf079b7
...
...
@@ -4726,10 +4726,16 @@ export default {
enabledHint
:
'
启用或禁用支付系统
'
,
enabledPaymentTypes
:
'
启用的服务商
'
,
enabledPaymentTypesHint
:
'
禁用服务商将同时禁用对应的实例。
'
,
findProvider
:
'
正在寻找合适的
EasyPay
服务商?
'
,
findProvider
:
'
正在寻找合适的
易支付
服务商?
'
,
minAmount
:
'
最低金额
'
,
maxAmount
:
'
最高金额
'
,
dailyLimit
:
'
每日限额
'
,
balanceRechargeMultiplier
:
'
余额充值倍率
'
,
balanceRechargeMultiplierHint
:
'
用户每支付 1 CNY 可获得多少 USD 余额
'
,
balanceRechargePreview
:
'
预览:1 CNY = {usd} USD
'
,
rechargeFeeRate
:
'
充值手续费率
'
,
rechargeFeeRateHint
:
'
用户充值时额外收取的手续费百分比,0 表示不收取手续费
'
,
rechargeFeePreview
:
'
预览:充值 100 元,手续费 {fee} 元
'
,
orderTimeout
:
'
订单超时时间
'
,
orderTimeoutHint
:
'
单位:分钟,至少 1 分钟
'
,
maxPendingOrders
:
'
最大待支付订单数
'
,
...
...
@@ -5512,6 +5518,8 @@ export default {
payment
:
{
title
:
'
充值/订阅
'
,
amountLabel
:
'
充值金额
'
,
paymentAmount
:
'
支付金额
'
,
creditedBalance
:
'
到账余额
'
,
quickAmounts
:
'
快捷金额
'
,
customAmount
:
'
自定义金额
'
,
enterAmount
:
'
输入金额
'
,
...
...
@@ -5567,6 +5575,10 @@ export default {
orderNo
:
'
订单编号
'
,
amount
:
'
金额
'
,
payAmount
:
'
实付
'
,
creditedAmount
:
'
到账金额
'
,
fee
:
'
手续费
'
,
baseAmount
:
'
充值金额
'
,
includedInPayAmount
:
'
已含在实付金额中
'
,
status
:
'
状态
'
,
paymentMethod
:
'
支付方式
'
,
createdAt
:
'
创建时间
'
,
...
...
@@ -5596,6 +5608,7 @@ export default {
amountTooLow
:
'
最低金额为 {min}
'
,
amountTooHigh
:
'
最高金额为 {max}
'
,
amountNoMethod
:
'
该金额没有可用的支付方式
'
,
rechargeRatePreview
:
'
当前倍率:1 CNY = {usd} USD
'
,
refundReason
:
'
退款原因
'
,
refundReasonPlaceholder
:
'
请描述您的退款原因
'
,
stripeLoadFailed
:
'
支付组件加载失败,请刷新页面重试
'
,
...
...
frontend/src/types/payment.ts
View file @
9bf079b7
...
...
@@ -32,6 +32,7 @@ export interface PaymentConfig {
max_pending_orders
:
number
order_timeout_minutes
:
number
balance_disabled
:
boolean
balance_recharge_multiplier
:
number
enabled_payment_types
:
PaymentType
[]
help_image_url
:
string
help_text
:
string
...
...
@@ -62,6 +63,8 @@ export interface CheckoutInfoResponse {
global_max
:
number
plans
:
SubscriptionPlan
[]
balance_disabled
:
boolean
balance_recharge_multiplier
:
number
recharge_fee_rate
:
number
help_text
:
string
help_image_url
:
string
stripe_publishable_key
:
string
...
...
@@ -155,10 +158,12 @@ export interface CreateOrderRequest {
export
interface
CreateOrderResult
{
order_id
:
number
amount
:
number
pay_url
?:
string
qr_code
?:
string
client_secret
?:
string
pay_amount
:
number
fee_rate
:
number
expires_at
:
string
payment_mode
?:
string
}
...
...
frontend/src/views/admin/SettingsView.vue
View file @
9bf079b7
...
...
@@ -2371,10 +2371,25 @@
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.preview
'
)
}}
<
/label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{
(
form.payment_product_name_prefix || 'Sub2API'
)
+ ' 100 ' +
(
form.payment_product_name_suffix || 'CNY'
)
}}
</
div
><
/div
>
<
/div
>
<!--
Row
2
:
Balance
toggle
+
amounts
-->
<
div
class
=
"
grid grid-cols-2 gap-3 sm:grid-cols-
4
"
>
<
div
class
=
"
grid grid-cols-2 gap-3 sm:grid-cols-
5
"
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.minAmount
'
)
}}
<
/label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.maxAmount
'
)
}}
<
/label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.dailyLimit
'
)
}}
<
/label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.balanceRechargeMultiplier
'
)
}}
<
/label
>
<
input
:
value
=
"
form.payment_balance_recharge_multiplier || ''
"
@
input
=
"
form.payment_balance_recharge_multiplier = parseFloat(($event.target as HTMLInputElement).value) || 1
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0.01
"
class
=
"
input
"
/>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.balanceRechargeMultiplierHint
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs font-medium text-primary-600 dark:text-primary-400
"
>
{{
t
(
'
admin.settings.payment.balanceRechargePreview
'
,
{
usd
:
(
Number
(
form
.
payment_balance_recharge_multiplier
)
||
1
).
toFixed
(
2
)
}
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.rechargeFeeRate
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
:
value
=
"
form.payment_recharge_fee_rate ?? ''
"
@
input
=
"
form.payment_recharge_fee_rate = Math.min(100, Math.max(0, Math.round(parseFloat(($event.target as HTMLInputElement).value || '0') * 100) / 100))
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0
"
max
=
"
100
"
class
=
"
input pr-8
"
/>
<
span
class
=
"
pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400
"
>%<
/span
>
<
/div
>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.rechargeFeeRateHint
'
)
}}
<
/p
>
<
p
v
-
if
=
"
(Number(form.payment_recharge_fee_rate) || 0) > 0
"
class
=
"
mt-1 text-xs font-medium text-primary-600 dark:text-primary-400
"
>
{{
t
(
'
admin.settings.payment.rechargeFeePreview
'
,
{
fee
:
(
Number
(
form
.
payment_recharge_fee_rate
)
||
0
).
toFixed
(
2
)
}
)
}}
<
/p
>
<
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.orderTimeout
'
)
}}
<
span
class
=
"
text-red-500
"
>*<
/span></
label
><
input
v
-
model
.
number
=
"
form.payment_order_timeout_minutes
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
required
/><
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.orderTimeoutHint
'
)
}}
<
/p></
div
>
<
/div
>
<!--
Row
3
:
Pending
orders
+
load
balance
+
cancel
rate
limit
(
all
in
one
row
)
-->
...
...
@@ -2968,7 +2983,7 @@ const form = reactive<SettingsForm>({
home_content
:
''
,
backend_mode_enabled
:
false
,
hide_ccs_import_button
:
false
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_balance_recharge_multiplier
:
1
,
payment_recharge_fee_rate
:
0
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
table_default_page_size
:
tablePageSizeDefault
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
...
...
@@ -3627,6 +3642,8 @@ async function saveSettings() {
payment_max_pending_orders
:
Number
(
form
.
payment_max_pending_orders
)
||
0
,
payment_order_timeout_minutes
:
Number
(
form
.
payment_order_timeout_minutes
)
||
0
,
payment_balance_disabled
:
form
.
payment_balance_disabled
,
payment_balance_recharge_multiplier
:
Number
(
form
.
payment_balance_recharge_multiplier
)
||
1
,
payment_recharge_fee_rate
:
Number
(
form
.
payment_recharge_fee_rate
)
||
0
,
payment_enabled_types
:
form
.
payment_enabled_types
,
payment_load_balance_strategy
:
form
.
payment_load_balance_strategy
,
payment_product_name_prefix
:
form
.
payment_product_name_prefix
,
...
...
frontend/src/views/admin/orders/AdminOrdersView.vue
View file @
9bf079b7
...
...
@@ -35,7 +35,7 @@
{{
t
(
'
payment.admin.retry
'
)
}}
</button>
<template
v-if=
"row.status === 'REFUND_REQUESTED'"
>
<span
v-if=
"row.refund_amount"
class=
"rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
>
$
{{
row
.
refund_amount
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.refund_amount"
class=
"rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
>
{{
row
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
{{
row
.
refund_amount
.
toFixed
(
2
)
}}
</span>
<button
@
click=
"openRefundDialog(row)"
class=
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"
>
<Icon
name=
"check"
size=
"sm"
/>
{{
t
(
'
payment.admin.approveRefund
'
)
}}
...
...
@@ -62,14 +62,14 @@
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderId') }}
</p><p
class=
"font-mono text-sm font-medium text-gray-900 dark:text-white"
>
#{{ selectedOrder.id }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderNo') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ selectedOrder.out_trade_no }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.status') }}
</p><OrderStatusBadge
:status=
"selectedOrder.status"
/></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{ selectedOrder.amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.payAmount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{ selectedOrder.pay_amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}
{{ selectedOrder.amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.payAmount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{ selectedOrder.pay_amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.paymentMethod') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.feeRate') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
(
selectedOrder.fee_rate
* 100).toFixed(1)
}}%
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.feeRate') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ selectedOrder.fee_rate }}%
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.createdAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.created_at) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.expiresAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.expires_at) }}
</p></div>
<div
v-if=
"selectedOrder.paid_at"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.paidAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.paid_at) }}
</p></div>
<div
v-if=
"selectedOrder.refund_amount"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundAmount') }}
</p><p
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
$
{{ selectedOrder.refund_amount.toFixed(2) }}
</p></div>
<div
v-if=
"selectedOrder.refund_amount"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundAmount') }}
</p><p
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}
{{ selectedOrder.refund_amount.toFixed(2) }}
</p></div>
<div
v-if=
"selectedOrder.refund_reason"
class=
"col-span-2"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundReason') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ selectedOrder.refund_reason }}
</p></div>
<!-- Refund request info -->
<div
v-if=
"selectedOrder.refund_requested_at"
class=
"col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600"
>
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
9bf079b7
...
...
@@ -37,8 +37,20 @@
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
order
.
out_trade_no
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
order
.
pay_amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.baseAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
baseAmount
.
toFixed
(
2
)
}}
</span>
</div>
<div
v-if=
"order.fee_rate > 0"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.fee
'
)
}}
(
{{
order
.
fee_rate
}}
%)
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
feeAmount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</span>
<span
class=
"font-bold text-primary-600 dark:text-primary-400"
>
¥
{{
order
.
pay_amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
v-if=
"order.amount !== order.pay_amount"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.creditedAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
order
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
order
.
amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.paymentMethod
'
)
}}
</span>
...
...
@@ -58,7 +70,7 @@
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
returnInfo
.
outTradeNo
}}
</span>
</div>
<div
v-if=
"returnInfo.money"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.
a
mount
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.
payA
mount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
returnInfo
.
money
}}
</span>
</div>
<div
v-if=
"returnInfo.type"
class=
"flex justify-between"
>
...
...
@@ -104,6 +116,18 @@ const returnInfo = ref<ReturnInfo | null>(null)
const
SUCCESS_STATUSES
=
new
Set
([
'
COMPLETED
'
,
'
PAID
'
,
'
RECHARGING
'
])
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
const
baseAmount
=
computed
(()
=>
{
if
(
!
order
.
value
||
order
.
value
.
fee_rate
<=
0
)
return
order
.
value
?.
pay_amount
??
0
return
Math
.
round
((
order
.
value
.
pay_amount
/
(
1
+
order
.
value
.
fee_rate
/
100
))
*
100
)
/
100
})
/** 手续费 = pay_amount - baseAmount */
const
feeAmount
=
computed
(()
=>
{
if
(
!
order
.
value
||
order
.
value
.
fee_rate
<=
0
)
return
0
return
Math
.
round
((
order
.
value
.
pay_amount
-
baseAmount
.
value
)
*
100
)
/
100
})
const
isSuccess
=
computed
(()
=>
{
// Always prioritize actual order status from backend
if
(
order
.
value
)
{
...
...
frontend/src/views/user/PaymentView.vue
View file @
9bf079b7
...
...
@@ -28,7 +28,9 @@
<
template
v-else-if=
"paymentPhase === 'stripe'"
>
<StripePaymentInline
:order-id=
"paymentState.orderId"
:amount=
"paymentState.amount"
:client-secret=
"paymentState.clientSecret"
:order-type=
"paymentState.orderType || undefined"
:publishable-key=
"checkout.stripe_publishable_key"
:pay-amount=
"paymentState.payAmount"
@
success=
"onPaymentSuccess"
...
...
@@ -67,20 +69,27 @@
@
select=
"selectedMethod = $event"
/>
</div>
<div
v-if=
"
feeRate > 0 &&
validAmount > 0"
class=
"card p-6"
>
<div
v-if=
"validAmount > 0"
class=
"card p-6"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.
amountLabel
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.
paymentAmount
'
)
}}
</span>
<span
class=
"text-gray-900 dark:text-white"
>
¥
{{
validAmount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<div
v-if=
"feeRate > 0"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.fee
'
)
}}
(
{{
feeRate
}}
%)
</span>
<span
class=
"text-gray-900 dark:text-white"
>
¥
{{
feeAmount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"
>
<div
v-if=
"feeRate > 0"
class=
"flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"
>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.actualPay
'
)
}}
</span>
<span
class=
"text-lg font-bold text-primary-600 dark:text-primary-400"
>
¥
{{
totalAmount
.
toFixed
(
2
)
}}
</span>
</div>
<div
v-if=
"balanceRechargeMultiplier !== 1"
class=
"flex justify-between"
:class=
"
{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate
<
=
0
}"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.creditedBalance
'
)
}}
</span>
<span
class=
"text-gray-900 dark:text-white"
>
$
{{
creditedAmount
.
toFixed
(
2
)
}}
</span>
</div>
<p
v-if=
"balanceRechargeMultiplier !== 1"
class=
"border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{{
t
(
'
payment.rechargeRatePreview
'
,
{
usd
:
balanceRechargeMultiplier
.
toFixed
(
2
)
}
)
}}
<
/p
>
<
/div
>
<
/div
>
<
button
:
class
=
"
['btn w-full py-3 text-base font-medium', paymentButtonClass]
"
:
disabled
=
"
!canSubmit || submitting
"
@
click
=
"
handleSubmitRecharge
"
>
...
...
@@ -88,7 +97,7 @@
<
span
class
=
"
h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent
"
><
/span
>
{{
t
(
'
common.processing
'
)
}}
<
/span
>
<span
v-else
>
{{
t
(
'
payment.createOrder
'
)
}}
¥
{{
(
feeRate
>
0
&&
validAmount
>
0
?
totalAmount
:
validAmount
)
.
toFixed
(
2
)
}}
</span>
<
span
v
-
else
>
{{
t
(
'
payment.createOrder
'
)
}}
¥
{{
totalAmount
.
toFixed
(
2
)
}}
<
/span
>
<
/button
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
...
...
@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
isMobileDevice
}
from
'
@/utils/device
'
import
type
{
SubscriptionPlan
,
CheckoutInfoResponse
}
from
'
@/types/payment
'
import
type
{
SubscriptionPlan
,
CheckoutInfoResponse
,
OrderType
}
from
'
@/types/payment
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AmountInput
from
'
@/components/payment/AmountInput.vue
'
import
PaymentMethodSelector
from
'
@/components/payment/PaymentMethodSelector.vue
'
...
...
@@ -302,11 +311,21 @@ const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const
paymentPhase
=
ref
<
'
select
'
|
'
paying
'
|
'
stripe
'
>
(
'
select
'
)
const
paymentState
=
ref
({
orderId
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
)
const
paymentState
=
ref
<
{
orderId
:
number
amount
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
payUrl
:
string
clientSecret
:
string
payAmount
:
number
orderType
:
OrderType
|
''
}
>
({
orderId
:
0
,
amount
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
)
function
resetPayment
()
{
paymentPhase
.
value
=
'
select
'
paymentState
.
value
=
{
orderId
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
paymentState
.
value
=
{
orderId
:
0
,
amount
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
}
function
onPaymentDone
()
{
...
...
@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) {
// All checkout data from single API call
const
checkout
=
ref
<
CheckoutInfoResponse
>
({
methods
:
{
}
,
global_min
:
0
,
global_max
:
0
,
plans
:
[],
balance_disabled
:
false
,
help_text
:
''
,
help_image_url
:
''
,
stripe_publishable_key
:
''
,
plans
:
[],
balance_disabled
:
false
,
balance_recharge_multiplier
:
1
,
recharge_fee_rate
:
0
,
help_text
:
''
,
help_image_url
:
''
,
stripe_publishable_key
:
''
,
}
)
const
tabs
=
computed
(()
=>
{
...
...
@@ -354,6 +373,11 @@ const tabs = computed(() => {
const
enabledMethods
=
computed
(()
=>
Object
.
keys
(
checkout
.
value
.
methods
))
const
validAmount
=
computed
(()
=>
amount
.
value
??
0
)
const
balanceRechargeMultiplier
=
computed
(()
=>
{
const
multiplier
=
checkout
.
value
.
balance_recharge_multiplier
return
multiplier
>
0
?
multiplier
:
1
}
)
const
creditedAmount
=
computed
(()
=>
Math
.
round
((
validAmount
.
value
*
balanceRechargeMultiplier
.
value
)
*
100
)
/
100
)
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const
planGridClass
=
computed
(()
=>
{
...
...
@@ -390,7 +414,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() =>
}
)
)
const
feeRate
=
computed
(()
=>
selectedLimit
.
value
?.
fee_rate
??
0
)
const
feeRate
=
computed
(()
=>
checkout
.
value
?.
recharge_
fee_rate
??
0
)
const
feeAmount
=
computed
(()
=>
feeRate
.
value
>
0
&&
validAmount
.
value
>
0
?
Math
.
ceil
(((
validAmount
.
value
*
feeRate
.
value
)
/
100
)
*
100
)
/
100
...
...
@@ -518,7 +542,7 @@ async function confirmSubscribe() {
await
createOrder
(
selectedPlan
.
value
.
price
,
'
subscription
'
,
selectedPlan
.
value
.
id
)
}
async
function
createOrder
(
orderAmount
:
number
,
orderType
:
string
,
planId
?:
number
)
{
async
function
createOrder
(
orderAmount
:
number
,
orderType
:
OrderType
,
planId
?:
number
)
{
submitting
.
value
=
true
errorMessage
.
value
=
''
try
{
...
...
@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
if
(
result
.
client_secret
)
{
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
''
,
clientSecret
:
result
.
client_secret
,
payAmount
:
result
.
pay_amount
,
orderType
,
...
...
@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
}
else
if
(
isMobileDevice
()
&&
result
.
pay_url
)
{
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
result
.
pay_url
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
,
...
...
@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
}
else
if
(
result
.
qr_code
)
{
// QR mode: show QR code inline
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
qrCode
:
result
.
qr_code
,
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
result
.
qr_code
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
,
...
...
@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
// Redirect/popup mode: open payment URL, show waiting state inline
openWindow
(
result
.
pay_url
)
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
result
.
pay_url
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
,
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment