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
97f14b7a
Unverified
Commit
97f14b7a
authored
Apr 11, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 11, 2026
Browse files
Merge pull request #1572 from touwaeriol/feat/payment-system-v2
feat(payment): add complete payment system with multi-provider support
parents
1ef3782d
6793503e
Changes
174
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/payment/PaymentQRDialog.vue
0 → 100644
View file @
97f14b7a
<
template
>
<BaseDialog
:show=
"show"
:title=
"dialogTitle"
width=
"narrow"
@
close=
"handleClose"
>
<!-- QR Code + Polling State -->
<div
v-if=
"!success"
class=
"flex flex-col items-center space-y-4"
>
<!-- QR Code mode -->
<template
v-if=
"qrUrl"
>
<div
class=
"rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800"
>
<canvas
ref=
"qrCanvas"
class=
"mx-auto"
></canvas>
</div>
<p
v-if=
"scanHint"
class=
"text-center text-sm text-gray-500 dark:text-gray-400"
>
{{
scanHint
}}
</p>
</
template
>
<!-- Popup window waiting mode (no QR code) -->
<
template
v-else
>
<div
class=
"flex flex-col items-center py-4"
>
<div
class=
"h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
<p
class=
"mt-4 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.payInNewWindowHint
'
)
}}
</p>
<button
v-if=
"payUrl"
class=
"btn btn-secondary mt-3 text-sm"
@
click=
"reopenPopup"
>
{{
t
(
'
payment.qr.openPayWindow
'
)
}}
</button>
</div>
</
template
>
<!-- Countdown -->
<div
v-if=
"expired"
class=
"text-center"
>
<p
class=
"text-lg font-medium text-red-500"
>
{{ t('payment.qr.expired') }}
</p>
</div>
<div
v-else
class=
"text-center"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ qrUrl ? t('payment.qr.expiresIn') : '' }}
</p>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{ countdownDisplay }}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{ t('payment.qr.waitingPayment') }}
</p>
</div>
</div>
<!-- Success State -->
<div
v-else
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{ t('payment.result.success') }}
</p>
<div
v-if=
"paidOrder"
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderId') }}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#{{ paidOrder.id }}
</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"
>
${{ paidOrder.pay_amount.toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
v-if=
"!success && !expired"
class=
"btn btn-secondary"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
<button
v-if=
"success"
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
<button
v-if=
"expired"
class=
"btn btn-primary"
@
click=
"handleClose"
>
{{
t
(
'
payment.result.backToRecharge
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
QRCode
from
'
qrcode
'
import
alipayIcon
from
'
@/assets/icons/alipay.svg
'
import
wxpayIcon
from
'
@/assets/icons/wxpay.svg
'
const
props
=
defineProps
<
{
show
:
boolean
orderId
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
/** URL for reopening the payment popup window */
payUrl
?:
string
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
success
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
paymentStore
=
usePaymentStore
()
const
appStore
=
useAppStore
()
const
qrCanvas
=
ref
<
HTMLCanvasElement
|
null
>
(
null
)
const
qrUrl
=
ref
(
''
)
const
remainingSeconds
=
ref
(
0
)
const
expired
=
ref
(
false
)
const
cancelling
=
ref
(
false
)
const
success
=
ref
(
false
)
const
paidOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
let
pollTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
isAlipay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
alipay
'
))
const
isWxpay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
wxpay
'
))
const
dialogTitle
=
computed
(()
=>
{
if
(
success
.
value
)
return
t
(
'
payment.result.success
'
)
if
(
!
qrUrl
.
value
)
return
t
(
'
payment.qr.payInNewWindow
'
)
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipay
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpay
'
)
return
t
(
'
payment.qr.scanToPay
'
)
})
const
scanHint
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipayHint
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpayHint
'
)
return
''
})
const
countdownDisplay
=
computed
(()
=>
{
const
m
=
Math
.
floor
(
remainingSeconds
.
value
/
60
)
const
s
=
remainingSeconds
.
value
%
60
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
})
function
getLogoForType
():
string
|
null
{
if
(
isAlipay
.
value
)
return
alipayIcon
if
(
isWxpay
.
value
)
return
wxpayIcon
return
null
}
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
}
}
async
function
renderQR
()
{
await
nextTick
()
if
(
!
qrCanvas
.
value
||
!
qrUrl
.
value
)
return
const
logoSrc
=
getLogoForType
()
await
QRCode
.
toCanvas
(
qrCanvas
.
value
,
qrUrl
.
value
,
{
width
:
220
,
margin
:
2
,
errorCorrectionLevel
:
logoSrc
?
'
H
'
:
'
M
'
,
})
if
(
!
logoSrc
)
return
const
canvas
=
qrCanvas
.
value
const
ctx
=
canvas
.
getContext
(
'
2d
'
)
if
(
!
ctx
)
return
const
img
=
new
Image
()
img
.
src
=
logoSrc
img
.
onload
=
()
=>
{
const
logoSize
=
40
const
x
=
(
canvas
.
width
-
logoSize
)
/
2
const
y
=
(
canvas
.
height
-
logoSize
)
/
2
const
pad
=
4
ctx
.
fillStyle
=
'
#FFFFFF
'
ctx
.
beginPath
()
const
r
=
5
ctx
.
moveTo
(
x
-
pad
+
r
,
y
-
pad
)
ctx
.
arcTo
(
x
+
logoSize
+
pad
,
y
-
pad
,
x
+
logoSize
+
pad
,
y
+
logoSize
+
pad
,
r
)
ctx
.
arcTo
(
x
+
logoSize
+
pad
,
y
+
logoSize
+
pad
,
x
-
pad
,
y
+
logoSize
+
pad
,
r
)
ctx
.
arcTo
(
x
-
pad
,
y
+
logoSize
+
pad
,
x
-
pad
,
y
-
pad
,
r
)
ctx
.
arcTo
(
x
-
pad
,
y
-
pad
,
x
+
logoSize
+
pad
,
y
-
pad
,
r
)
ctx
.
fill
()
ctx
.
drawImage
(
img
,
x
,
y
,
logoSize
,
logoSize
)
}
}
async
function
pollStatus
()
{
if
(
!
props
.
orderId
)
return
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
if
(
!
order
)
return
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
cleanup
()
paidOrder
.
value
=
order
success
.
value
=
true
emit
(
'
success
'
)
}
else
if
(
order
.
status
===
'
EXPIRED
'
||
order
.
status
===
'
CANCELLED
'
||
order
.
status
===
'
FAILED
'
)
{
cleanup
()
expired
.
value
=
true
}
}
function
startCountdown
(
seconds
:
number
)
{
remainingSeconds
.
value
=
Math
.
max
(
0
,
seconds
)
if
(
remainingSeconds
.
value
<=
0
)
{
expired
.
value
=
true
return
}
countdownTimer
=
setInterval
(()
=>
{
remainingSeconds
.
value
--
if
(
remainingSeconds
.
value
<=
0
)
{
expired
.
value
=
true
cleanup
()
}
},
1000
)
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
cleanup
()
emit
(
'
close
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
function
handleClose
()
{
cleanup
()
emit
(
'
close
'
)
}
function
handleDone
()
{
cleanup
()
emit
(
'
close
'
)
}
function
cleanup
()
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
);
pollTimer
=
null
}
if
(
countdownTimer
)
{
clearInterval
(
countdownTimer
);
countdownTimer
=
null
}
}
function
init
()
{
// Reset state
success
.
value
=
false
paidOrder
.
value
=
null
expired
.
value
=
false
cancelling
.
value
=
false
qrUrl
.
value
=
props
.
qrCode
let
seconds
=
30
*
60
if
(
props
.
expiresAt
)
{
const
expiresAt
=
new
Date
(
props
.
expiresAt
)
seconds
=
Math
.
floor
((
expiresAt
.
getTime
()
-
Date
.
now
())
/
1000
)
}
startCountdown
(
seconds
)
pollTimer
=
setInterval
(
pollStatus
,
3000
)
renderQR
()
}
// Watch for dialog open/close
watch
(()
=>
props
.
show
,
(
isOpen
)
=>
{
if
(
isOpen
)
{
init
()
}
else
{
cleanup
()
}
})
watch
(
qrUrl
,
()
=>
renderQR
())
onUnmounted
(()
=>
cleanup
())
</
script
>
frontend/src/components/payment/PaymentStatusPanel.vue
0 → 100644
View file @
97f14b7a
<
template
>
<div
class=
"space-y-4"
>
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
<!-- Success -->
<template
v-if=
"outcome === 'success'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
props
.
orderType
===
'
subscription
'
?
t
(
'
payment.result.subscriptionSuccess
'
)
:
t
(
'
payment.result.success
'
)
}}
</p>
<div
v-if=
"paidOrder"
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#
{{
paidOrder
.
id
}}
</span>
</div>
<div
v-if=
"paidOrder.out_trade_no"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderNo
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
paidOrder
.
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"
>
$
{{
paidOrder
.
pay_amount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- Cancelled -->
<
template
v-else-if=
"outcome === 'cancelled'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<svg
class=
"h-8 w-8 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.qr.cancelled
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.cancelledDesc
'
)
}}
</p>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- Expired / Failed -->
<
template
v-else-if=
"outcome === 'expired'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30"
>
<svg
class=
"h-8 w-8 text-orange-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.qr.expired
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.expiredDesc
'
)
}}
</p>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- ═══ Active States: QR or Popup waiting ═══ -->
<!-- QR Code Mode -->
<
template
v-else-if=
"qrUrl"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4"
>
<p
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
scanTitle
}}
</p>
<div
:class=
"['relative rounded-lg border-2 p-4', qrBorderClass]"
>
<canvas
ref=
"qrCanvas"
class=
"mx-auto"
></canvas>
<!-- Brand logo overlay -->
<div
class=
"pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span
:class=
"['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]"
>
<img
:src=
"isAlipay ? alipayIcon : wxpayIcon"
alt=
""
class=
"h-5 w-5 brightness-0 invert"
/>
</span>
</div>
</div>
<p
v-if=
"scanHint"
class=
"text-center text-sm text-gray-500 dark:text-gray-400"
>
{{
scanHint
}}
</p>
</div>
</div>
<div
class=
"card p-4 text-center"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.expiresIn
'
)
}}
</p>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{
countdownDisplay
}}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
payment.qr.waitingPayment
'
)
}}
</p>
</div>
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
<!-- Waiting for Popup/Redirect Mode -->
<
template
v-else
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.payInNewWindowHint
'
)
}}
</p>
<button
v-if=
"payUrl"
class=
"btn btn-secondary text-sm"
@
click=
"reopenPopup"
>
{{
t
(
'
payment.qr.openPayWindow
'
)
}}
</button>
</div>
</div>
<div
class=
"card p-4 text-center"
>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{
countdownDisplay
}}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
payment.qr.waitingPayment
'
)
}}
</p>
</div>
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
QRCode
from
'
qrcode
'
import
alipayIcon
from
'
@/assets/icons/alipay.svg
'
import
wxpayIcon
from
'
@/assets/icons/wxpay.svg
'
const
props
=
defineProps
<
{
orderId
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
payUrl
?:
string
orderType
?:
string
}
>
()
const
emit
=
defineEmits
<
{
done
:
[];
success
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
paymentStore
=
usePaymentStore
()
const
appStore
=
useAppStore
()
const
qrCanvas
=
ref
<
HTMLCanvasElement
|
null
>
(
null
)
const
qrUrl
=
ref
(
''
)
const
remainingSeconds
=
ref
(
0
)
const
cancelling
=
ref
(
false
)
const
paidOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
const
outcome
=
ref
<
'
success
'
|
'
cancelled
'
|
'
expired
'
|
null
>
(
null
)
let
pollTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
isAlipay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
alipay
'
))
const
isWxpay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
wxpay
'
))
const
qrBorderClass
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
'
border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20
'
if
(
isWxpay
.
value
)
return
'
border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20
'
return
'
border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800
'
})
const
qrLogoBgClass
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
'
bg-[#00AEEF]
'
if
(
isWxpay
.
value
)
return
'
bg-[#2BB741]
'
return
'
bg-gray-400
'
})
const
scanTitle
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipay
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpay
'
)
return
t
(
'
payment.qr.scanToPay
'
)
})
const
scanHint
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipayHint
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpayHint
'
)
return
''
})
const
countdownDisplay
=
computed
(()
=>
{
const
m
=
Math
.
floor
(
remainingSeconds
.
value
/
60
)
const
s
=
remainingSeconds
.
value
%
60
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
})
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
}
}
async
function
renderQR
()
{
await
nextTick
()
if
(
!
qrCanvas
.
value
||
!
qrUrl
.
value
)
return
await
QRCode
.
toCanvas
(
qrCanvas
.
value
,
qrUrl
.
value
,
{
width
:
220
,
margin
:
2
,
errorCorrectionLevel
:
'
H
'
,
})
}
async
function
pollStatus
()
{
if
(
!
props
.
orderId
||
outcome
.
value
)
return
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
if
(
!
order
)
return
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
cleanup
()
paidOrder
.
value
=
order
outcome
.
value
=
'
success
'
emit
(
'
success
'
)
}
else
if
(
order
.
status
===
'
CANCELLED
'
)
{
cleanup
()
outcome
.
value
=
'
cancelled
'
}
else
if
(
order
.
status
===
'
EXPIRED
'
||
order
.
status
===
'
FAILED
'
)
{
cleanup
()
outcome
.
value
=
'
expired
'
}
}
function
startCountdown
(
seconds
:
number
)
{
remainingSeconds
.
value
=
Math
.
max
(
0
,
seconds
)
if
(
remainingSeconds
.
value
<=
0
)
{
outcome
.
value
=
'
expired
'
;
return
}
countdownTimer
=
setInterval
(()
=>
{
remainingSeconds
.
value
--
if
(
remainingSeconds
.
value
<=
0
)
{
outcome
.
value
=
'
expired
'
;
cleanup
()
}
},
1000
)
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
cleanup
()
outcome
.
value
=
'
cancelled
'
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
function
handleDone
()
{
cleanup
();
emit
(
'
done
'
)
}
function
cleanup
()
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
);
pollTimer
=
null
}
if
(
countdownTimer
)
{
clearInterval
(
countdownTimer
);
countdownTimer
=
null
}
}
// Initialize on mount
qrUrl
.
value
=
props
.
qrCode
let
seconds
=
30
*
60
if
(
props
.
expiresAt
)
{
seconds
=
Math
.
floor
((
new
Date
(
props
.
expiresAt
).
getTime
()
-
Date
.
now
())
/
1000
)
}
startCountdown
(
seconds
)
pollTimer
=
setInterval
(
pollStatus
,
3000
)
renderQR
()
watch
(()
=>
qrUrl
.
value
,
()
=>
renderQR
())
onUnmounted
(()
=>
cleanup
())
</
script
>
frontend/src/components/payment/ProviderCard.vue
0 → 100644
View file @
97f14b7a
<
template
>
<div
:class=
"[
'group relative rounded-lg border transition-all',
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50',
]"
:title=
"!enabled ? t('admin.settings.payment.typeDisabled') + ' — ' + t('admin.settings.payment.enableTypesFirst') : undefined"
>
<div
:class=
"[
'flex items-center justify-between px-4 py-2.5',
!enabled && 'pointer-events-none',
]"
>
<!-- Left: icon + name + key badge + type badges -->
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'rounded-md p-1.5',
provider.enabled && enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700',
]"
>
<Icon
name=
"server"
size=
"sm"
:class=
"provider.enabled && enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'"
/>
</div>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
provider
.
name
}}
</span>
<span
class=
"text-xs text-gray-400 dark:text-gray-500"
>
{{
keyLabel
}}
</span>
<span
v-if=
"provider.payment_mode"
class=
"text-xs text-gray-400 dark:text-gray-500"
>
·
{{
modeLabel
}}
</span>
<span
v-if=
"enabled && availableTypes.length"
class=
"text-xs text-gray-300 dark:text-gray-600"
>
|
</span>
<div
v-if=
"enabled"
class=
"flex items-center gap-1"
>
<button
v-for=
"pt in availableTypes"
:key=
"pt.value"
type=
"button"
@
click=
"emit('toggleType', pt.value)"
:class=
"[
'rounded px-2 py-0.5 text-xs font-medium transition-all',
isSelected(pt.value)
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-500',
]"
>
{{
pt
.
label
}}
</button>
</div>
</div>
<!-- Right: toggles + actions -->
<div
class=
"flex items-center gap-4"
>
<ToggleSwitch
:label=
"t('common.enabled')"
:checked=
"provider.enabled"
@
toggle=
"emit('toggleField', 'enabled')"
/>
<ToggleSwitch
:label=
"t('admin.settings.payment.refundEnabled')"
:checked=
"provider.refund_enabled"
@
toggle=
"emit('toggleField', 'refund_enabled')"
/>
<div
class=
"flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600"
>
<button
type=
"button"
@
click=
"emit('edit')"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<Icon
name=
"edit"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
<button
type=
"button"
@
click=
"emit('delete')"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon
name=
"trash"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ToggleSwitch
from
'
./ToggleSwitch.vue
'
import
type
{
ProviderInstance
}
from
'
@/types/payment
'
import
type
{
TypeOption
}
from
'
./providerConfig
'
import
{
PAYMENT_MODE_QRCODE
,
PAYMENT_MODE_POPUP
}
from
'
./providerConfig
'
const
PROVIDER_KEY_LABELS
:
Record
<
string
,
string
>
=
{
easypay
:
'
admin.settings.payment.providerEasypay
'
,
alipay
:
'
admin.settings.payment.providerAlipay
'
,
wxpay
:
'
admin.settings.payment.providerWxpay
'
,
stripe
:
'
admin.settings.payment.providerStripe
'
,
}
const
props
=
defineProps
<
{
provider
:
ProviderInstance
enabled
:
boolean
availableTypes
:
TypeOption
[]
}
>
()
const
emit
=
defineEmits
<
{
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleType
:
[
type
:
string
]
edit
:
[]
delete
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
keyLabel
=
computed
(()
=>
t
(
PROVIDER_KEY_LABELS
[
props
.
provider
.
provider_key
]
||
props
.
provider
.
provider_key
))
const
modeLabel
=
computed
(()
=>
{
if
(
props
.
provider
.
payment_mode
===
PAYMENT_MODE_QRCODE
)
return
t
(
'
admin.settings.payment.modeQRCode
'
)
if
(
props
.
provider
.
payment_mode
===
PAYMENT_MODE_POPUP
)
return
t
(
'
admin.settings.payment.modePopup
'
)
return
''
})
function
isSelected
(
type
:
string
):
boolean
{
return
props
.
provider
.
supported_types
.
includes
(
type
)
}
</
script
>
frontend/src/components/payment/StripePaymentInline.vue
0 → 100644
View file @
97f14b7a
<
template
>
<div
class=
"space-y-4"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if=
"initError"
class=
"card p-6 text-center"
>
<p
class=
"text-sm text-red-600 dark:text-red-400"
>
{{
initError
}}
</p>
<button
class=
"btn btn-secondary mt-4"
@
click=
"$emit('back')"
>
{{
t
(
'
payment.result.backToRecharge
'
)
}}
</button>
</div>
<!-- Success -->
<template
v-else-if=
"success"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.result.success
'
)
}}
</p>
<div
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#
{{
orderId
}}
</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"
>
$
{{
payAmount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
<button
class=
"btn btn-primary"
@
click=
"$emit('done')"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<
template
v-else
>
<!-- Amount -->
<div
class=
"card overflow-hidden"
>
<div
class=
"bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center"
>
<p
class=
"text-sm font-medium text-indigo-200"
>
{{
t
(
'
payment.actualPay
'
)
}}
</p>
<p
class=
"mt-1 text-3xl font-bold text-white"
>
$
{{
payAmount
.
toFixed
(
2
)
}}
</p>
</div>
</div>
<!-- Stripe Payment Element -->
<div
class=
"card p-6"
>
<div
ref=
"stripeMount"
class=
"min-h-[200px]"
></div>
<p
v-if=
"error"
class=
"mt-4 text-sm text-red-600 dark:text-red-400"
>
{{
error
}}
</p>
<button
class=
"btn btn-stripe mt-6 w-full py-3 text-base"
:disabled=
"submitting || !ready"
@
click=
"handlePay"
>
<span
v-if=
"submitting"
class=
"flex items-center justify-center gap-2"
>
<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.stripePay
'
)
}}
</span>
</button>
</div>
<!-- Cancel order -->
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRouter
}
from
'
vue-router
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
STRIPE_POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
Stripe
,
StripeElements
}
from
'
@stripe/stripe-js
'
import
Icon
from
'
@/components/icons/Icon.vue
'
// Stripe payment methods that open a popup (redirect or QR code)
const
POPUP_METHODS
=
new
Set
([
'
alipay
'
,
'
wechat_pay
'
])
const
props
=
defineProps
<
{
orderId
:
number
clientSecret
:
string
publishableKey
:
string
payAmount
:
number
}
>
()
const
emit
=
defineEmits
<
{
success
:
[];
done
:
[];
back
:
[];
redirect
:
[
orderId
:
number
,
payUrl
:
string
]
}
>
()
const
{
t
}
=
useI18n
()
const
router
=
useRouter
()
const
appStore
=
useAppStore
()
const
stripeMount
=
ref
<
HTMLElement
|
null
>
(
null
)
const
loading
=
ref
(
true
)
const
initError
=
ref
(
''
)
const
error
=
ref
(
''
)
const
submitting
=
ref
(
false
)
const
cancelling
=
ref
(
false
)
const
success
=
ref
(
false
)
const
ready
=
ref
(
false
)
const
selectedType
=
ref
(
''
)
let
stripeInstance
:
Stripe
|
null
=
null
let
elementsInstance
:
StripeElements
|
null
=
null
onMounted
(
async
()
=>
{
try
{
const
{
loadStripe
}
=
await
import
(
'
@stripe/stripe-js
'
)
const
stripe
=
await
loadStripe
(
props
.
publishableKey
)
if
(
!
stripe
)
{
initError
.
value
=
t
(
'
payment.stripeLoadFailed
'
);
return
}
stripeInstance
=
stripe
loading
.
value
=
false
await
nextTick
()
if
(
!
stripeMount
.
value
)
return
const
isDark
=
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
const
elements
=
stripe
.
elements
({
clientSecret
:
props
.
clientSecret
,
appearance
:
{
theme
:
isDark
?
'
night
'
:
'
stripe
'
,
variables
:
{
borderRadius
:
'
8px
'
}
},
})
elementsInstance
=
elements
const
paymentElement
=
elements
.
create
(
'
payment
'
,
{
layout
:
'
tabs
'
,
paymentMethodOrder
:
[
'
alipay
'
,
'
wechat_pay
'
,
'
card
'
,
'
link
'
],
}
as
Record
<
string
,
unknown
>
)
paymentElement
.
mount
(
stripeMount
.
value
)
paymentElement
.
on
(
'
ready
'
,
()
=>
{
ready
.
value
=
true
})
paymentElement
.
on
(
'
change
'
,
(
event
:
{
value
:
{
type
:
string
}
})
=>
{
selectedType
.
value
=
event
.
value
.
type
})
}
catch
(
err
:
unknown
)
{
initError
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.stripeLoadFailed
'
))
}
finally
{
loading
.
value
=
false
}
})
async
function
handlePay
()
{
if
(
!
stripeInstance
||
!
elementsInstance
||
submitting
.
value
)
return
// Alipay / WeChat Pay: open popup for redirect or QR display
if
(
POPUP_METHODS
.
has
(
selectedType
.
value
))
{
const
popupUrl
=
router
.
resolve
({
path
:
'
/payment/stripe-popup
'
,
query
:
{
order_id
:
String
(
props
.
orderId
),
method
:
selectedType
.
value
,
amount
:
String
(
props
.
payAmount
),
},
}).
href
const
popup
=
window
.
open
(
popupUrl
,
'
paymentPopup
'
,
STRIPE_POPUP_WINDOW_FEATURES
)
const
onReady
=
(
event
:
MessageEvent
)
=>
{
if
(
event
.
source
!==
popup
||
event
.
data
?.
type
!==
'
STRIPE_POPUP_READY
'
)
return
window
.
removeEventListener
(
'
message
'
,
onReady
)
popup
?.
postMessage
({
type
:
'
STRIPE_POPUP_INIT
'
,
clientSecret
:
props
.
clientSecret
,
publishableKey
:
props
.
publishableKey
,
},
window
.
location
.
origin
)
}
window
.
addEventListener
(
'
message
'
,
onReady
)
emit
(
'
redirect
'
,
props
.
orderId
,
popupUrl
)
return
}
// Card / Link: confirm inline
submitting
.
value
=
true
error
.
value
=
''
try
{
const
{
error
:
stripeError
}
=
await
stripeInstance
.
confirmPayment
({
elements
:
elementsInstance
,
confirmParams
:
{
return_url
:
window
.
location
.
origin
+
'
/payment/result?order_id=
'
+
props
.
orderId
+
'
&status=success
'
,
},
redirect
:
'
if_required
'
,
})
if
(
stripeError
)
{
error
.
value
=
stripeError
.
message
||
t
(
'
payment.result.failed
'
)
}
else
{
success
.
value
=
true
emit
(
'
success
'
)
}
}
catch
(
err
:
unknown
)
{
error
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.result.failed
'
))
}
finally
{
submitting
.
value
=
false
}
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
emit
(
'
back
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
</
script
>
frontend/src/components/payment/SubscriptionPlanCard.vue
0 → 100644
View file @
97f14b7a
<
template
>
<div
:class=
"[
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
'hover:shadow-xl hover:-translate-y-0.5',
borderClass,
'bg-white dark:bg-dark-800',
]"
>
<!-- Colored top accent bar -->
<div
:class=
"['h-1.5', accentClass]"
/>
<div
class=
"flex flex-1 flex-col p-4"
>
<!-- Header: name + badge + price -->
<div
class=
"mb-3 flex items-start justify-between gap-2"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<h3
class=
"truncate text-base font-bold text-gray-900 dark:text-white"
>
{{
plan
.
name
}}
</h3>
<span
:class=
"['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]"
>
{{
pLabel
}}
</span>
</div>
<p
v-if=
"plan.description"
class=
"mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2"
>
{{
plan
.
description
}}
</p>
</div>
<div
class=
"shrink-0 text-right"
>
<div
class=
"flex items-baseline gap-1"
>
<span
class=
"text-xs text-gray-400 dark:text-dark-500"
>
$
</span>
<span
:class=
"['text-2xl font-extrabold tracking-tight', textClass]"
>
{{
plan
.
price
}}
</span>
</div>
<span
class=
"text-[11px] text-gray-400 dark:text-dark-500"
>
/
{{
validitySuffix
}}
</span>
<div
v-if=
"plan.original_price"
class=
"mt-0.5 flex items-center justify-end gap-1.5"
>
<span
class=
"text-xs text-gray-400 line-through dark:text-dark-500"
>
$
{{
plan
.
original_price
}}
</span>
<span
:class=
"['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]"
>
{{
discountText
}}
</span>
</div>
</div>
</div>
<!-- Group quota info (compact) -->
<div
class=
"mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50"
>
<div
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.rate
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
rateDisplay
}}
</span>
</div>
<div
v-if=
"plan.daily_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.dailyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
daily_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.weekly_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.weeklyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
weekly_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.monthly_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.monthlyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
monthly_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.quota
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.planCard.unlimited
'
)
}}
</span>
</div>
<div
v-if=
"modelScopeLabels.length > 0"
class=
"col-span-2 flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.models
'
)
}}
</span>
<div
class=
"flex flex-wrap justify-end gap-1"
>
<span
v-for=
"scope in modelScopeLabels"
:key=
"scope"
class=
"rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300"
>
{{
scope
}}
</span>
</div>
</div>
</div>
<!-- Features list (compact) -->
<div
v-if=
"plan.features.length > 0"
class=
"mb-3 space-y-1"
>
<div
v-for=
"feature in plan.features"
:key=
"feature"
class=
"flex items-start gap-1.5"
>
<svg
:class=
"['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
<span
class=
"text-xs text-gray-600 dark:text-gray-300"
>
{{
feature
}}
</span>
</div>
</div>
<div
class=
"flex-1"
/>
<!-- Subscribe Button -->
<button
type=
"button"
:class=
"['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@
click=
"emit('select', plan)"
>
{{
isRenewal
?
t
(
'
payment.renewNow
'
)
:
t
(
'
payment.subscribeNow
'
)
}}
</button>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SubscriptionPlan
}
from
'
@/types/payment
'
import
type
{
UserSubscription
}
from
'
@/types
'
import
{
platformAccentBarClass
,
platformBadgeLightClass
,
platformBorderClass
,
platformTextClass
,
platformIconClass
,
platformButtonClass
,
platformDiscountClass
,
platformLabel
,
}
from
'
@/utils/platformColors
'
const
props
=
defineProps
<
{
plan
:
SubscriptionPlan
;
activeSubscriptions
?:
UserSubscription
[]
}
>
()
const
emit
=
defineEmits
<
{
select
:
[
plan
:
SubscriptionPlan
]
}
>
()
const
{
t
}
=
useI18n
()
const
platform
=
computed
(()
=>
props
.
plan
.
group_platform
||
''
)
const
isRenewal
=
computed
(()
=>
props
.
activeSubscriptions
?.
some
(
s
=>
s
.
group_id
===
props
.
plan
.
group_id
&&
s
.
status
===
'
active
'
)
??
false
)
// Derived color classes from central config
const
accentClass
=
computed
(()
=>
platformAccentBarClass
(
platform
.
value
))
const
borderClass
=
computed
(()
=>
platformBorderClass
(
platform
.
value
))
const
badgeLightClass
=
computed
(()
=>
platformBadgeLightClass
(
platform
.
value
))
const
textClass
=
computed
(()
=>
platformTextClass
(
platform
.
value
))
const
iconClass
=
computed
(()
=>
platformIconClass
(
platform
.
value
))
const
btnClass
=
computed
(()
=>
platformButtonClass
(
platform
.
value
))
const
discountClass
=
computed
(()
=>
platformDiscountClass
(
platform
.
value
))
const
pLabel
=
computed
(()
=>
platformLabel
(
platform
.
value
))
const
discountText
=
computed
(()
=>
{
if
(
!
props
.
plan
.
original_price
||
props
.
plan
.
original_price
<=
0
)
return
''
const
pct
=
Math
.
round
((
1
-
props
.
plan
.
price
/
props
.
plan
.
original_price
)
*
100
)
return
pct
>
0
?
`-
${
pct
}
%`
:
''
})
const
rateDisplay
=
computed
(()
=>
{
const
rate
=
props
.
plan
.
rate_multiplier
??
1
return
`×
${
Number
(
rate
.
toPrecision
(
10
))}
`
})
const
MODEL_SCOPE_LABELS
:
Record
<
string
,
string
>
=
{
claude
:
'
Claude
'
,
gemini_text
:
'
Gemini
'
,
gemini_image
:
'
Imagen
'
,
}
const
modelScopeLabels
=
computed
(()
=>
{
const
scopes
=
props
.
plan
.
supported_model_scopes
if
(
!
scopes
||
scopes
.
length
===
0
)
return
[]
return
scopes
.
map
(
s
=>
MODEL_SCOPE_LABELS
[
s
]
||
s
)
})
const
validitySuffix
=
computed
(()
=>
{
const
u
=
props
.
plan
.
validity_unit
||
'
day
'
if
(
u
===
'
month
'
)
return
t
(
'
payment.perMonth
'
)
if
(
u
===
'
year
'
)
return
t
(
'
payment.perYear
'
)
return
`
${
props
.
plan
.
validity_days
}${
t
(
'
payment.days
'
)}
`
})
</
script
>
frontend/src/components/payment/ToggleSwitch.vue
0 → 100644
View file @
97f14b7a
<
template
>
<label
class=
"flex items-center gap-1.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
label
}}
</span>
<button
type=
"button"
role=
"switch"
:aria-checked=
"checked"
@
click=
"emit('toggle')"
:class=
"[
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
checked ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
checked ? 'translate-x-4' : 'translate-x-0',
]"
/>
</button>
</label>
</
template
>
<
script
setup
lang=
"ts"
>
defineProps
<
{
label
:
string
;
checked
:
boolean
}
>
()
const
emit
=
defineEmits
<
{
toggle
:
[]
}
>
()
</
script
>
frontend/src/components/payment/orderUtils.ts
0 → 100644
View file @
97f14b7a
/**
* Shared utility functions for payment order display.
* Used by AdminOrderDetail, AdminOrderTable, AdminRefundDialog, AdminOrdersView, etc.
*/
const
STATUS_BADGE_MAP
:
Record
<
string
,
string
>
=
{
PENDING
:
'
badge-warning
'
,
PAID
:
'
badge-info
'
,
RECHARGING
:
'
badge-info
'
,
COMPLETED
:
'
badge-success
'
,
EXPIRED
:
'
badge-secondary
'
,
CANCELLED
:
'
badge-secondary
'
,
FAILED
:
'
badge-danger
'
,
REFUND_REQUESTED
:
'
badge-warning
'
,
REFUNDING
:
'
badge-warning
'
,
PARTIALLY_REFUNDED
:
'
badge-warning
'
,
REFUNDED
:
'
badge-info
'
,
REFUND_FAILED
:
'
badge-danger
'
,
}
const
REFUNDABLE_STATUSES
=
[
'
COMPLETED
'
,
'
PARTIALLY_REFUNDED
'
,
'
REFUND_REQUESTED
'
,
'
REFUND_FAILED
'
]
export
function
statusBadgeClass
(
status
:
string
):
string
{
return
STATUS_BADGE_MAP
[
status
]
||
'
badge-secondary
'
}
export
function
canRefund
(
status
:
string
):
boolean
{
return
REFUNDABLE_STATUSES
.
includes
(
status
)
}
export
function
formatOrderDateTime
(
dateStr
:
string
):
string
{
if
(
!
dateStr
)
return
'
-
'
return
new
Date
(
dateStr
).
toLocaleString
()
}
frontend/src/components/payment/providerConfig.ts
0 → 100644
View file @
97f14b7a
/**
* Shared constants and types for payment provider management.
*/
// --- Types ---
export
interface
ConfigFieldDef
{
key
:
string
label
:
string
sensitive
:
boolean
optional
?:
boolean
defaultValue
?:
string
}
export
interface
TypeOption
{
value
:
string
label
:
string
}
/** Callback URL paths for a provider. */
export
interface
CallbackPaths
{
notifyUrl
?:
string
returnUrl
?:
string
}
// --- Constants ---
/** Maps provider key → available payment types. */
export
const
PROVIDER_SUPPORTED_TYPES
:
Record
<
string
,
string
[]
>
=
{
easypay
:
[
'
alipay
'
,
'
wxpay
'
],
alipay
:
[
'
alipay
'
],
wxpay
:
[
'
wxpay
'
],
stripe
:
[
'
card
'
,
'
alipay
'
,
'
wxpay
'
,
'
link
'
],
}
/** Available payment modes for EasyPay providers. */
export
const
EASYPAY_PAYMENT_MODES
=
[
'
qrcode
'
,
'
popup
'
]
as
const
/** Fixed display order for user-facing payment methods */
export
const
METHOD_ORDER
=
[
'
alipay
'
,
'
alipay_direct
'
,
'
wxpay
'
,
'
wxpay_direct
'
,
'
stripe
'
]
as
const
/** Payment mode constants */
export
const
PAYMENT_MODE_QRCODE
=
'
qrcode
'
export
const
PAYMENT_MODE_POPUP
=
'
popup
'
/** Window features for payment popup windows */
export
const
POPUP_WINDOW_FEATURES
=
'
width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes
'
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
export
const
STRIPE_POPUP_WINDOW_FEATURES
=
'
width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes
'
/** Webhook paths for each provider (relative to origin). */
export
const
WEBHOOK_PATHS
:
Record
<
string
,
string
>
=
{
easypay
:
'
/api/v1/payment/webhook/easypay
'
,
alipay
:
'
/api/v1/payment/webhook/alipay
'
,
wxpay
:
'
/api/v1/payment/webhook/wxpay
'
,
stripe
:
'
/api/v1/payment/webhook/stripe
'
,
}
export
const
RETURN_PATH
=
'
/payment/result
'
/** Fixed callback paths per provider — displayed as read-only after base URL. */
export
const
PROVIDER_CALLBACK_PATHS
:
Record
<
string
,
CallbackPaths
>
=
{
easypay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
easypay
,
returnUrl
:
RETURN_PATH
},
alipay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
alipay
,
returnUrl
:
RETURN_PATH
},
wxpay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
wxpay
},
// stripe: no callback URL config needed (webhook is separate)
}
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
export
const
PROVIDER_CONFIG_FIELDS
:
Record
<
string
,
ConfigFieldDef
[]
>
=
{
easypay
:
[
{
key
:
'
pid
'
,
label
:
'
PID
'
,
sensitive
:
false
},
{
key
:
'
pkey
'
,
label
:
'
PKey
'
,
sensitive
:
true
},
{
key
:
'
apiBase
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
cidAlipay
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
{
key
:
'
cidWxpay
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
],
alipay
:
[
{
key
:
'
appId
'
,
label
:
'
App ID
'
,
sensitive
:
false
},
{
key
:
'
privateKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKey
'
,
label
:
''
,
sensitive
:
true
},
],
wxpay
:
[
{
key
:
'
appId
'
,
label
:
'
App ID
'
,
sensitive
:
false
},
{
key
:
'
mchId
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
privateKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
apiV3Key
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKeyId
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
{
key
:
'
certSerial
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
],
stripe
:
[
{
key
:
'
secretKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publishableKey
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
webhookSecret
'
,
label
:
''
,
sensitive
:
true
},
],
}
// --- Helpers ---
/** Resolve type label for display. */
export
function
resolveTypeLabel
(
typeVal
:
string
,
_providerKey
:
string
,
allTypes
:
TypeOption
[],
_redirectLabel
:
string
,
):
TypeOption
{
return
allTypes
.
find
(
pt
=>
pt
.
value
===
typeVal
)
||
{
value
:
typeVal
,
label
:
typeVal
}
}
/** Get available type options for a provider key. */
export
function
getAvailableTypes
(
providerKey
:
string
,
allTypes
:
TypeOption
[],
redirectLabel
:
string
,
):
TypeOption
[]
{
const
types
=
PROVIDER_SUPPORTED_TYPES
[
providerKey
]
||
[]
return
types
.
map
(
t
=>
resolveTypeLabel
(
t
,
providerKey
,
allTypes
,
redirectLabel
))
}
/** Extract base URL from a full callback URL by removing the known path suffix. */
export
function
extractBaseUrl
(
fullUrl
:
string
,
path
:
string
):
string
{
if
(
!
fullUrl
)
return
''
if
(
fullUrl
.
endsWith
(
path
))
return
fullUrl
.
slice
(
0
,
-
path
.
length
)
// Fallback: try to extract origin
try
{
return
new
URL
(
fullUrl
).
origin
}
catch
{
return
fullUrl
}
}
frontend/src/i18n/locales/en.ts
View file @
97f14b7a
...
...
@@ -308,6 +308,8 @@ export default {
chooseFile
:
'
Choose File
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
today
:
'
Today
'
,
tomorrow
:
'
Tomorrow
'
,
unknown
:
'
Unknown
'
,
minutes
:
'
min
'
,
time
:
{
...
...
@@ -353,7 +355,11 @@ export default {
mySubscriptions
:
'
My Subscriptions
'
,
buySubscription
:
'
Recharge / Subscription
'
,
docs
:
'
Docs
'
,
sora
:
'
Sora Studio
'
myOrders
:
'
My Orders
'
,
orderManagement
:
'
Orders
'
,
paymentDashboard
:
'
Payment Dashboard
'
,
paymentConfig
:
'
Payment Config
'
,
paymentPlans
:
'
Plans
'
},
// Auth
...
...
@@ -4182,7 +4188,7 @@ export default {
gateway
:
'
Gateway
'
,
email
:
'
Email
'
,
backup
:
'
Backup
'
,
data
:
'
Sora Storage
'
,
payment
:
'
Payment
'
,
},
emailTabDisabledTitle
:
'
Email Verification Not Enabled
'
,
emailTabDisabledHint
:
'
Enable email verification in the Security tab to configure SMTP settings.
'
,
...
...
@@ -4435,6 +4441,100 @@ export default {
moveUp
:
'
Move Up
'
,
moveDown
:
'
Move Down
'
,
},
payment
:
{
title
:
'
Payment Settings
'
,
description
:
'
Configure payment system options
'
,
enabled
:
'
Enable Payment
'
,
enabledHint
:
'
Enable or disable the payment system
'
,
enabledPaymentTypes
:
'
Enabled Providers
'
,
enabledPaymentTypesHint
:
'
Disabling a provider will also disable its instances
'
,
minAmount
:
'
Minimum Amount
'
,
maxAmount
:
'
Maximum Amount
'
,
dailyLimit
:
'
Daily Limit
'
,
orderTimeout
:
'
Order Timeout
'
,
orderTimeoutHint
:
'
In minutes, minimum 1
'
,
maxPendingOrders
:
'
Max Pending Orders
'
,
cancelRateLimit
:
'
Limit Cancel Rate
'
,
cancelRateLimitHint
:
'
When enabled, users who exceed the cancel limit within the time window cannot create new orders
'
,
cancelRateLimitEvery
:
'
Every
'
,
cancelRateLimitAllowMax
:
'
allow max
'
,
cancelRateLimitTimes
:
'
cancels
'
,
cancelRateLimitWindow
:
'
Window
'
,
cancelRateLimitUnit
:
'
Unit
'
,
cancelRateLimitMax
:
'
Max Cancels
'
,
cancelRateLimitUnitMinute
:
'
Minutes
'
,
cancelRateLimitUnitHour
:
'
Hours
'
,
cancelRateLimitUnitDay
:
'
Days
'
,
cancelRateLimitWindowMode
:
'
Window Mode
'
,
cancelRateLimitWindowModeRolling
:
'
Rolling
'
,
cancelRateLimitWindowModeFixed
:
'
Fixed
'
,
helpText
:
'
Help Text
'
,
helpImageUrl
:
'
Help Image URL
'
,
manageProviders
:
'
Manage Providers
'
,
balancePaymentDisabled
:
'
Disable Balance Recharge
'
,
noLimit
:
'
Empty = no limit
'
,
helpImage
:
'
Help Image
'
,
helpImagePlaceholder
:
'
Upload or enter image URL
'
,
helpTextPlaceholder
:
'
Enter help text...
'
,
providerEasypay
:
'
EasyPay
'
,
providerAlipay
:
'
Alipay (Direct)
'
,
providerWxpay
:
'
WeChat Pay (Direct)
'
,
providerStripe
:
'
Stripe
'
,
typeDisabled
:
'
type disabled
'
,
enableTypesFirst
:
'
Enable at least one payment type above first
'
,
easypayRedirect
:
'
Redirect
'
,
paymentMode
:
'
Payment Mode
'
,
modeRedirect
:
'
Redirect
'
,
modeQRCode
:
'
QR Code
'
,
modePopup
:
'
Popup
'
,
validationNameRequired
:
'
Provider name is required
'
,
validationTypesRequired
:
'
Please select at least one supported payment type
'
,
validationFieldRequired
:
'
{field} is required
'
,
field_apiBase
:
'
API Base URL
'
,
field_notifyUrl
:
'
Notify URL
'
,
field_returnUrl
:
'
Return URL
'
,
callbackBaseUrl
:
'
Callback Base URL
'
,
field_privateKey
:
'
Private Key
'
,
field_publicKey
:
'
Public Key
'
,
field_mchId
:
'
Merchant ID
'
,
field_apiV3Key
:
'
API v3 Key
'
,
field_publicKeyId
:
'
Public Key ID
'
,
field_certSerial
:
'
Certificate Serial
'
,
field_secretKey
:
'
Secret Key
'
,
field_publishableKey
:
'
Publishable Key
'
,
field_webhookSecret
:
'
Webhook Secret
'
,
field_cid
:
'
Channel ID
'
,
field_cidAlipay
:
'
Alipay Channel ID
'
,
field_cidWxpay
:
'
WeChat Channel ID
'
,
stripeWebhookHint
:
'
Configure the following URL as a Webhook endpoint in Stripe Dashboard:
'
,
limitsTitle
:
'
Limits
'
,
limitSingleMin
:
'
Min per order
'
,
limitSingleMax
:
'
Max per order
'
,
limitDaily
:
'
Daily limit
'
,
limitsHint
:
'
All empty = use global config; partially filled = empty means no limit
'
,
limitsUseGlobal
:
'
Use global
'
,
limitsNoLimit
:
'
No limit
'
,
productNamePrefix
:
'
Product Name Prefix
'
,
productNameSuffix
:
'
Product Name Suffix
'
,
preview
:
'
Preview
'
,
loadBalanceStrategy
:
'
Load Balance Strategy
'
,
strategyRoundRobin
:
'
Round Robin
'
,
strategyLeastAmount
:
'
Least Daily Amount
'
,
providerManagement
:
'
Provider Management
'
,
providerManagementDesc
:
'
Manage payment provider instances
'
,
createProvider
:
'
Add Provider
'
,
editProvider
:
'
Edit Provider
'
,
deleteProvider
:
'
Delete Provider
'
,
deleteProviderConfirm
:
'
Are you sure you want to delete this provider?
'
,
providerName
:
'
Provider Name
'
,
providerKey
:
'
Provider Type
'
,
selectProviderKey
:
'
Select Provider Type
'
,
providerConfig
:
'
Credentials
'
,
noProviders
:
'
No provider instances configured
'
,
supportedTypes
:
'
Supported Payment Types
'
,
supportedTypesHint
:
'
Comma-separated, e.g. alipay,wxpay
'
,
refundEnabled
:
'
Allow Refund
'
,
},
smtp
:
{
title
:
'
SMTP Settings
'
,
description
:
'
Configure email sending for verification codes
'
,
...
...
@@ -5084,4 +5184,263 @@ export default {
}
},
// Payment System
payment
:
{
title
:
'
Recharge / Subscription
'
,
amountLabel
:
'
Amount
'
,
quickAmounts
:
'
Quick Amounts
'
,
customAmount
:
'
Custom Amount
'
,
enterAmount
:
'
Enter amount
'
,
paymentMethod
:
'
Payment Method
'
,
fee
:
'
Fee
'
,
actualPay
:
'
Actual Payment
'
,
createOrder
:
'
Confirm Payment
'
,
methods
:
{
easypay
:
'
EasyPay
'
,
alipay
:
'
Alipay
'
,
wxpay
:
'
WeChat Pay
'
,
stripe
:
'
Stripe
'
,
card
:
'
Card
'
,
link
:
'
Link
'
,
alipay_direct
:
'
Alipay (Direct)
'
,
wxpay_direct
:
'
WeChat Pay (Direct)
'
,
},
status
:
{
pending
:
'
Pending
'
,
paid
:
'
Paid
'
,
recharging
:
'
Recharging
'
,
completed
:
'
Completed
'
,
expired
:
'
Expired
'
,
cancelled
:
'
Cancelled
'
,
failed
:
'
Failed
'
,
refund_requested
:
'
Refund Requested
'
,
refunding
:
'
Refunding
'
,
refunded
:
'
Refunded
'
,
partially_refunded
:
'
Partially Refunded
'
,
refund_failed
:
'
Refund Failed
'
,
},
qr
:
{
scanToPay
:
'
Scan to Pay
'
,
scanAlipay
:
'
Alipay QR Payment
'
,
scanWxpay
:
'
WeChat QR Payment
'
,
scanAlipayHint
:
'
Open Alipay on your phone and scan the QR code to pay
'
,
scanWxpayHint
:
'
Open WeChat on your phone and scan the QR code to pay
'
,
payInNewWindow
:
'
Complete Payment in New Window
'
,
payInNewWindowHint
:
'
The payment page has opened in a new window. Please complete the payment there and return to this page.
'
,
openPayWindow
:
'
Reopen Payment Page
'
,
expiresIn
:
'
Expires in
'
,
expired
:
'
Order Expired
'
,
expiredDesc
:
'
This order has expired. Please create a new one.
'
,
cancelled
:
'
Order Cancelled
'
,
cancelledDesc
:
'
You have cancelled this payment.
'
,
waitingPayment
:
'
Waiting for payment...
'
,
cancelOrder
:
'
Cancel Order
'
,
},
orders
:
{
title
:
'
My Orders
'
,
empty
:
'
No orders yet
'
,
orderId
:
'
Order ID
'
,
orderNo
:
'
Order No.
'
,
amount
:
'
Amount
'
,
payAmount
:
'
Paid
'
,
status
:
'
Status
'
,
paymentMethod
:
'
Payment Method
'
,
createdAt
:
'
Created
'
,
cancel
:
'
Cancel Order
'
,
userId
:
'
User ID
'
,
orderType
:
'
Order Type
'
,
actions
:
'
Actions
'
,
requestRefund
:
'
Request Refund
'
,
},
result
:
{
success
:
'
Payment Successful
'
,
subscriptionSuccess
:
'
Subscription Successful
'
,
failed
:
'
Payment Failed
'
,
backToRecharge
:
'
Back to Recharge
'
,
viewOrders
:
'
View Orders
'
,
},
currentBalance
:
'
Current Balance
'
,
rechargeAccount
:
'
Recharge Account
'
,
activeSubscription
:
'
Active Subscription
'
,
noActiveSubscription
:
'
No active subscription
'
,
tabTopUp
:
'
Top Up
'
,
tabSubscribe
:
'
Subscribe
'
,
noPlans
:
'
No subscription plans available
'
,
notAvailable
:
'
Top-up is currently unavailable
'
,
confirmSubscription
:
'
Confirm Subscription
'
,
confirmCancel
:
'
Are you sure you want to cancel this order?
'
,
amountTooLow
:
'
Minimum amount is {min}
'
,
amountTooHigh
:
'
Maximum amount is {max}
'
,
amountNoMethod
:
'
No payment method available for this amount
'
,
refundReason
:
'
Refund Reason
'
,
refundReasonPlaceholder
:
'
Please describe your refund reason
'
,
stripeLoadFailed
:
'
Failed to load payment component. Please refresh and try again.
'
,
stripeMissingParams
:
'
Missing order ID or client secret
'
,
stripeNotConfigured
:
'
Stripe is not configured
'
,
errors
:
{
tooManyPending
:
'
Too many pending orders (max {max}). Please complete or cancel existing orders first.
'
,
cancelRateLimited
:
'
Too many cancellations. Please try again later.
'
,
PENDING_ORDERS
:
'
This provider has pending orders. Please wait for them to complete before making changes.
'
,
},
stripePay
:
'
Pay Now
'
,
stripeSuccessProcessing
:
'
Payment successful, processing your order...
'
,
stripePopup
:
{
redirecting
:
'
Redirecting to payment page...
'
,
loadingQr
:
'
Loading WeChat Pay QR code...
'
,
timeout
:
'
Timed out waiting for payment credentials, please retry
'
,
qrFailed
:
'
Failed to get WeChat Pay QR code
'
,
},
subscribeNow
:
'
Subscribe Now
'
,
renewNow
:
'
Renew
'
,
selectPlan
:
'
Select Plan
'
,
planFeatures
:
'
Features
'
,
planCard
:
{
rate
:
'
Rate
'
,
dailyLimit
:
'
Daily
'
,
weeklyLimit
:
'
Weekly
'
,
monthlyLimit
:
'
Monthly
'
,
quota
:
'
Quota
'
,
unlimited
:
'
Unlimited
'
,
models
:
'
Models
'
,
},
days
:
'
days
'
,
months
:
'
months
'
,
years
:
'
years
'
,
oneMonth
:
'
1 Month
'
,
oneYear
:
'
1 Year
'
,
perMonth
:
'
month
'
,
perYear
:
'
year
'
,
admin
:
{
tabs
:
{
overview
:
'
Overview
'
,
orders
:
'
Orders
'
,
channels
:
'
Channels
'
,
plans
:
'
Plans
'
,
},
todayRevenue
:
'
Today Revenue
'
,
totalRevenue
:
'
Total Revenue
'
,
todayOrders
:
'
Today Orders
'
,
orderCount
:
'
Order Count
'
,
avgAmount
:
'
Average Amount
'
,
revenue
:
'
Revenue
'
,
dailyRevenue
:
'
Daily Revenue
'
,
paymentDistribution
:
'
Payment Distribution
'
,
colUser
:
'
User
'
,
topUsers
:
'
Top Users
'
,
noData
:
'
No data
'
,
days
:
'
days
'
,
weeks
:
'
weeks
'
,
months
:
'
months
'
,
searchOrders
:
'
Search orders...
'
,
allStatuses
:
'
All Statuses
'
,
allPaymentTypes
:
'
All Payment Types
'
,
allOrderTypes
:
'
All Order Types
'
,
orderDetail
:
'
Order Detail
'
,
orderType
:
'
Order Type
'
,
orders
:
'
Orders
'
,
balanceOrder
:
'
Balance Top-Up
'
,
subscriptionOrder
:
'
Subscription
'
,
paidAt
:
'
Paid At
'
,
completedAt
:
'
Completed At
'
,
expiresAt
:
'
Expires At
'
,
feeRate
:
'
Fee Rate
'
,
refund
:
'
Refund
'
,
refundOrder
:
'
Refund Order
'
,
refundAmount
:
'
Refund Amount
'
,
maxRefundable
:
'
Max Refundable
'
,
refundReason
:
'
Refund Reason
'
,
refundReasonPlaceholder
:
'
Please enter refund reason
'
,
confirmRefund
:
'
Confirm Refund
'
,
refundSuccess
:
'
Refund successful
'
,
refundInfo
:
'
Refund Info
'
,
refundEnabled
:
'
Refund Enabled
'
,
alreadyRefunded
:
'
Already Refunded
'
,
deductBalance
:
'
Deduct Balance
'
,
deductBalanceHint
:
'
Subtract recharged amount from user balance
'
,
userBalance
:
'
User Balance
'
,
orderAmount
:
'
Order Amount
'
,
insufficientBalance
:
'
Insufficient balance — will deduct to $0
'
,
noDeduction
:
'
Will NOT deduct user balance
'
,
forceRefund
:
'
Force refund (ignore balance check)
'
,
orderCancelled
:
'
Order Cancelled
'
,
retry
:
'
Retry
'
,
retrySuccess
:
'
Retry successful
'
,
approveRefund
:
'
Approve Refund
'
,
retryRefund
:
'
Retry Refund
'
,
refundRequestInfo
:
'
Refund Request Info
'
,
refundRequestedAt
:
'
Requested At
'
,
refundRequestedBy
:
'
Requested By
'
,
refundRequestReason
:
'
Request Reason
'
,
auditLogs
:
'
Audit Logs
'
,
operator
:
'
Operator
'
,
channelName
:
'
Channel Name
'
,
channelDescription
:
'
Channel Description
'
,
createChannel
:
'
Create Channel
'
,
editChannel
:
'
Edit Channel
'
,
deleteChannel
:
'
Delete Channel
'
,
deleteChannelConfirm
:
'
Are you sure you want to delete this channel?
'
,
planName
:
'
Plan Name
'
,
planDescription
:
'
Plan Description
'
,
createPlan
:
'
Create Plan
'
,
editPlan
:
'
Edit Plan
'
,
deletePlan
:
'
Delete Plan
'
,
deletePlanConfirm
:
'
Are you sure you want to delete this plan?
'
,
originalPrice
:
'
Original Price
'
,
price
:
'
Price
'
,
validityDays
:
'
Validity (days)
'
,
validityUnit
:
'
Validity Unit
'
,
sortOrder
:
'
Sort Order
'
,
forSale
:
'
For Sale
'
,
onSale
:
'
On Sale
'
,
offSale
:
'
Off Sale
'
,
group
:
'
Group
'
,
groupId
:
'
Group ID
'
,
features
:
'
Features
'
,
featuresHint
:
'
One feature per line
'
,
featuresPlaceholder
:
'
Enter plan features...
'
,
providerManagement
:
'
Provider Management
'
,
providerManagementDesc
:
'
Manage payment provider instances
'
,
createProvider
:
'
Create Provider
'
,
editProvider
:
'
Edit Provider
'
,
deleteProvider
:
'
Delete Provider
'
,
deleteProviderConfirm
:
'
Are you sure you want to delete this provider?
'
,
providerName
:
'
Provider Name
'
,
providerKey
:
'
Provider Key
'
,
selectProviderKey
:
'
Select Provider Key
'
,
providerConfig
:
'
Provider Config
'
,
noProviders
:
'
No providers configured
'
,
noProvidersHint
:
'
Create a provider instance to start accepting payments
'
,
supportedTypes
:
'
Supported Payment Types
'
,
supportedTypesHint
:
'
Select the payment types this provider supports
'
,
rateMultiplier
:
'
Rate Multiplier
'
,
dashboardTitle
:
'
Payment Dashboard
'
,
dashboardDesc
:
'
Recharge order analytics and insights
'
,
daySuffix
:
'
d
'
,
paymentConfigTitle
:
'
Payment Config
'
,
paymentConfigDesc
:
'
Configure payment providers and settings
'
,
plansPageTitle
:
'
Subscription Plans
'
,
plansPageDesc
:
'
Manage subscription plan configuration
'
,
tabPlanConfig
:
'
Plan Configuration
'
,
tabUserSubs
:
'
User Subscriptions
'
,
selectGroup
:
'
Select a group
'
,
groupMissing
:
'
Missing
'
,
groupInfo
:
'
Group Info
'
,
platform
:
'
Platform
'
,
rateMultiplierLabel
:
'
Rate
'
,
dailyLimit
:
'
Daily Limit
'
,
weeklyLimit
:
'
Weekly Limit
'
,
monthlyLimit
:
'
Monthly Limit
'
,
unlimited
:
'
Unlimited
'
,
searchUserSubs
:
'
Search user subscriptions...
'
,
daily
:
'
D
'
,
weekly
:
'
W
'
,
monthly
:
'
M
'
,
subsStatus
:
{
active
:
'
Active
'
,
expired
:
'
Expired
'
,
revoked
:
'
Revoked
'
,
},
},
},
}
frontend/src/i18n/locales/zh.ts
View file @
97f14b7a
...
...
@@ -308,6 +308,8 @@ export default {
chooseFile
:
'
选择文件
'
,
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
today
:
'
今天
'
,
tomorrow
:
'
明天
'
,
unknown
:
'
未知
'
,
minutes
:
'
分钟
'
,
time
:
{
...
...
@@ -353,7 +355,11 @@ export default {
mySubscriptions
:
'
我的订阅
'
,
buySubscription
:
'
充值/订阅
'
,
docs
:
'
文档
'
,
sora
:
'
Sora 创作
'
myOrders
:
'
我的订单
'
,
orderManagement
:
'
订单管理
'
,
paymentDashboard
:
'
支付概览
'
,
paymentConfig
:
'
支付配置
'
,
paymentPlans
:
'
订阅套餐
'
},
// Auth
...
...
@@ -4347,7 +4353,7 @@ export default {
gateway
:
'
网关服务
'
,
email
:
'
邮件设置
'
,
backup
:
'
数据备份
'
,
data
:
'
Sora 存储
'
,
payment
:
'
支付设置
'
,
},
emailTabDisabledTitle
:
'
邮箱验证未启用
'
,
emailTabDisabledHint
:
'
请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。
'
,
...
...
@@ -4599,6 +4605,100 @@ export default {
moveUp
:
'
上移
'
,
moveDown
:
'
下移
'
,
},
payment
:
{
title
:
'
支付设置
'
,
description
:
'
配置支付系统选项
'
,
enabled
:
'
启用支付
'
,
enabledHint
:
'
启用或禁用支付系统
'
,
enabledPaymentTypes
:
'
启用的服务商
'
,
enabledPaymentTypesHint
:
'
禁用服务商将同时禁用对应的实例
'
,
minAmount
:
'
最低金额
'
,
maxAmount
:
'
最高金额
'
,
dailyLimit
:
'
每日限额
'
,
orderTimeout
:
'
订单超时时间
'
,
orderTimeoutHint
:
'
单位:分钟,至少 1 分钟
'
,
maxPendingOrders
:
'
最大待支付订单数
'
,
cancelRateLimit
:
'
限制取消频率
'
,
cancelRateLimitHint
:
'
启用后,用户在时间窗口内取消订单次数超限将无法创建新订单
'
,
cancelRateLimitEvery
:
'
每
'
,
cancelRateLimitAllowMax
:
'
最多
'
,
cancelRateLimitTimes
:
'
次
'
,
cancelRateLimitWindow
:
'
时间窗口
'
,
cancelRateLimitUnit
:
'
周期
'
,
cancelRateLimitMax
:
'
最大取消次数
'
,
cancelRateLimitUnitMinute
:
'
分钟
'
,
cancelRateLimitUnitHour
:
'
小时
'
,
cancelRateLimitUnitDay
:
'
天
'
,
cancelRateLimitWindowMode
:
'
窗口模式
'
,
cancelRateLimitWindowModeRolling
:
'
滚动
'
,
cancelRateLimitWindowModeFixed
:
'
固定
'
,
helpText
:
'
帮助文本
'
,
helpImageUrl
:
'
帮助图片链接
'
,
manageProviders
:
'
管理服务商
'
,
balancePaymentDisabled
:
'
禁用余额充值
'
,
noLimit
:
'
留空表示不限制
'
,
helpImage
:
'
帮助图片
'
,
helpImagePlaceholder
:
'
上传或输入图片链接
'
,
helpTextPlaceholder
:
'
输入帮助说明文本...
'
,
providerEasypay
:
'
易支付
'
,
providerAlipay
:
'
支付宝官方
'
,
providerWxpay
:
'
微信官方
'
,
providerStripe
:
'
Stripe
'
,
typeDisabled
:
'
类型已禁用
'
,
enableTypesFirst
:
'
请先在上方启用至少一种服务商
'
,
easypayRedirect
:
'
跳转
'
,
paymentMode
:
'
支付模式
'
,
modeRedirect
:
'
跳转
'
,
modeQRCode
:
'
二维码
'
,
modePopup
:
'
弹窗
'
,
validationNameRequired
:
'
服务商名称不能为空
'
,
validationTypesRequired
:
'
请至少选择一种支持的支付方式
'
,
validationFieldRequired
:
'
{field} 不能为空
'
,
field_apiBase
:
'
API 基础地址
'
,
field_notifyUrl
:
'
异步通知地址
'
,
field_returnUrl
:
'
同步跳转地址
'
,
callbackBaseUrl
:
'
回调基础地址
'
,
field_privateKey
:
'
私钥
'
,
field_publicKey
:
'
公钥
'
,
field_mchId
:
'
商户号
'
,
field_apiV3Key
:
'
API v3 密钥
'
,
field_publicKeyId
:
'
公钥 ID
'
,
field_certSerial
:
'
证书序列号
'
,
field_secretKey
:
'
密钥
'
,
field_publishableKey
:
'
公开密钥
'
,
field_webhookSecret
:
'
Webhook 密钥
'
,
field_cid
:
'
支付渠道 ID
'
,
field_cidAlipay
:
'
支付宝渠道 ID
'
,
field_cidWxpay
:
'
微信渠道 ID
'
,
stripeWebhookHint
:
'
请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:
'
,
limitsTitle
:
'
限额配置
'
,
limitSingleMin
:
'
单笔最低
'
,
limitSingleMax
:
'
单笔最高
'
,
limitDaily
:
'
每日限额
'
,
limitsHint
:
'
全部留空使用全局配置,部分填写时留空项表示不限制
'
,
limitsUseGlobal
:
'
使用全局配置
'
,
limitsNoLimit
:
'
不限制
'
,
productNamePrefix
:
'
商品名前缀
'
,
productNameSuffix
:
'
商品名后缀
'
,
preview
:
'
预览
'
,
loadBalanceStrategy
:
'
负载均衡策略
'
,
strategyRoundRobin
:
'
轮询
'
,
strategyLeastAmount
:
'
最少金额
'
,
providerManagement
:
'
服务商管理
'
,
providerManagementDesc
:
'
管理支付服务商实例
'
,
createProvider
:
'
添加服务商
'
,
editProvider
:
'
编辑服务商
'
,
deleteProvider
:
'
删除服务商
'
,
deleteProviderConfirm
:
'
确定要删除此服务商吗?
'
,
providerName
:
'
服务商名称
'
,
providerKey
:
'
服务商类型
'
,
selectProviderKey
:
'
选择服务商类型
'
,
providerConfig
:
'
凭证配置
'
,
noProviders
:
'
暂无服务商实例
'
,
supportedTypes
:
'
支持的支付方式
'
,
supportedTypesHint
:
'
逗号分隔,如 alipay,wxpay
'
,
refundEnabled
:
'
允许退款
'
,
},
smtp
:
{
title
:
'
SMTP 设置
'
,
description
:
'
配置用于发送验证码的邮件服务
'
,
...
...
@@ -5272,4 +5372,263 @@ export default {
}
},
// Payment System
payment
:
{
title
:
'
充值/订阅
'
,
amountLabel
:
'
充值金额
'
,
quickAmounts
:
'
快捷金额
'
,
customAmount
:
'
自定义金额
'
,
enterAmount
:
'
输入金额
'
,
paymentMethod
:
'
支付方式
'
,
fee
:
'
手续费
'
,
actualPay
:
'
实付金额
'
,
createOrder
:
'
确认支付
'
,
methods
:
{
easypay
:
'
易支付
'
,
alipay
:
'
支付宝
'
,
wxpay
:
'
微信支付
'
,
stripe
:
'
Stripe
'
,
card
:
'
银行卡
'
,
link
:
'
Link
'
,
alipay_direct
:
'
支付宝(直连)
'
,
wxpay_direct
:
'
微信支付(直连)
'
,
},
status
:
{
pending
:
'
待支付
'
,
paid
:
'
已支付
'
,
recharging
:
'
充值中
'
,
completed
:
'
已完成
'
,
expired
:
'
已过期
'
,
cancelled
:
'
已取消
'
,
failed
:
'
失败
'
,
refund_requested
:
'
退款申请中
'
,
refunding
:
'
退款中
'
,
refunded
:
'
已退款
'
,
partially_refunded
:
'
部分退款
'
,
refund_failed
:
'
退款失败
'
,
},
qr
:
{
scanToPay
:
'
请扫码支付
'
,
scanAlipay
:
'
支付宝扫码支付
'
,
scanWxpay
:
'
微信扫码支付
'
,
scanAlipayHint
:
'
请使用手机打开支付宝,扫描二维码完成支付
'
,
scanWxpayHint
:
'
请使用手机打开微信,扫描二维码完成支付
'
,
payInNewWindow
:
'
请在新窗口中完成支付
'
,
payInNewWindowHint
:
'
支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面
'
,
openPayWindow
:
'
重新打开支付页面
'
,
expiresIn
:
'
剩余支付时间
'
,
expired
:
'
订单已过期
'
,
expiredDesc
:
'
订单已超时,请重新创建订单
'
,
cancelled
:
'
订单已取消
'
,
cancelledDesc
:
'
您已取消本次支付
'
,
waitingPayment
:
'
等待支付...
'
,
cancelOrder
:
'
取消订单
'
,
},
orders
:
{
title
:
'
我的订单
'
,
empty
:
'
暂无订单
'
,
orderId
:
'
订单 ID
'
,
orderNo
:
'
订单编号
'
,
amount
:
'
金额
'
,
payAmount
:
'
实付
'
,
status
:
'
状态
'
,
paymentMethod
:
'
支付方式
'
,
createdAt
:
'
创建时间
'
,
cancel
:
'
取消订单
'
,
userId
:
'
用户 ID
'
,
orderType
:
'
订单类型
'
,
actions
:
'
操作
'
,
requestRefund
:
'
申请退款
'
,
},
result
:
{
success
:
'
支付成功
'
,
subscriptionSuccess
:
'
订阅成功
'
,
failed
:
'
支付失败
'
,
backToRecharge
:
'
返回充值
'
,
viewOrders
:
'
查看订单
'
,
},
currentBalance
:
'
当前余额
'
,
rechargeAccount
:
'
充值账户
'
,
activeSubscription
:
'
当前订阅
'
,
noActiveSubscription
:
'
暂无有效订阅
'
,
tabTopUp
:
'
充值
'
,
tabSubscribe
:
'
订阅
'
,
noPlans
:
'
暂无可用订阅套餐
'
,
notAvailable
:
'
充值功能暂未开放
'
,
confirmSubscription
:
'
确认订阅
'
,
confirmCancel
:
'
确定要取消此订单吗?
'
,
amountTooLow
:
'
最低金额为 {min}
'
,
amountTooHigh
:
'
最高金额为 {max}
'
,
amountNoMethod
:
'
该金额没有可用的支付方式
'
,
refundReason
:
'
退款原因
'
,
refundReasonPlaceholder
:
'
请描述您的退款原因
'
,
stripeLoadFailed
:
'
支付组件加载失败,请刷新页面重试
'
,
stripeMissingParams
:
'
缺少订单ID或支付密钥
'
,
stripeNotConfigured
:
'
Stripe 未配置
'
,
errors
:
{
tooManyPending
:
'
待支付订单过多(最多 {max} 个),请先完成或取消现有订单
'
,
cancelRateLimited
:
'
取消订单过于频繁,请稍后再试
'
,
PENDING_ORDERS
:
'
该服务商有未完成的订单,请等待订单完成后再操作
'
,
},
stripePay
:
'
立即支付
'
,
stripeSuccessProcessing
:
'
支付成功,正在处理订单...
'
,
stripePopup
:
{
redirecting
:
'
正在跳转到支付页面...
'
,
loadingQr
:
'
正在获取微信支付二维码...
'
,
timeout
:
'
等待支付凭证超时,请重试
'
,
qrFailed
:
'
未能获取微信支付二维码
'
,
},
subscribeNow
:
'
立即开通
'
,
renewNow
:
'
续费
'
,
selectPlan
:
'
选择套餐
'
,
planFeatures
:
'
功能特性
'
,
planCard
:
{
rate
:
'
倍率
'
,
dailyLimit
:
'
日限额
'
,
weeklyLimit
:
'
周限额
'
,
monthlyLimit
:
'
月限额
'
,
quota
:
'
配额
'
,
unlimited
:
'
无限制
'
,
models
:
'
模型
'
,
},
days
:
'
天
'
,
months
:
'
个月
'
,
years
:
'
年
'
,
oneMonth
:
'
1 个月
'
,
oneYear
:
'
1 年
'
,
perMonth
:
'
月
'
,
perYear
:
'
年
'
,
admin
:
{
tabs
:
{
overview
:
'
概览
'
,
orders
:
'
订单管理
'
,
channels
:
'
支付渠道
'
,
plans
:
'
订阅套餐
'
,
},
todayRevenue
:
'
今日收入
'
,
totalRevenue
:
'
总收入
'
,
todayOrders
:
'
今日订单
'
,
orderCount
:
'
订单数
'
,
avgAmount
:
'
平均金额
'
,
revenue
:
'
收入
'
,
dailyRevenue
:
'
每日收入
'
,
paymentDistribution
:
'
支付方式分布
'
,
colUser
:
'
用户
'
,
topUsers
:
'
消费排行
'
,
noData
:
'
暂无数据
'
,
days
:
'
天
'
,
weeks
:
'
周
'
,
months
:
'
月
'
,
searchOrders
:
'
搜索订单...
'
,
allStatuses
:
'
全部状态
'
,
allPaymentTypes
:
'
全部支付方式
'
,
allOrderTypes
:
'
全部订单类型
'
,
orderDetail
:
'
订单详情
'
,
orderType
:
'
订单类型
'
,
orders
:
'
订单
'
,
balanceOrder
:
'
余额充值
'
,
subscriptionOrder
:
'
订阅
'
,
paidAt
:
'
支付时间
'
,
completedAt
:
'
完成时间
'
,
expiresAt
:
'
过期时间
'
,
feeRate
:
'
手续费率
'
,
refund
:
'
退款
'
,
refundOrder
:
'
退款订单
'
,
refundAmount
:
'
退款金额
'
,
maxRefundable
:
'
最大可退金额
'
,
refundReason
:
'
退款原因
'
,
refundReasonPlaceholder
:
'
请输入退款原因
'
,
confirmRefund
:
'
确认退款
'
,
refundSuccess
:
'
退款成功
'
,
refundInfo
:
'
退款信息
'
,
refundEnabled
:
'
允许退款
'
,
alreadyRefunded
:
'
已退款
'
,
deductBalance
:
'
扣除余额
'
,
deductBalanceHint
:
'
从用户余额中扣回充值金额
'
,
userBalance
:
'
用户余额
'
,
orderAmount
:
'
订单金额
'
,
insufficientBalance
:
'
余额不足,将扣至 $0
'
,
noDeduction
:
'
将不扣除用户余额
'
,
forceRefund
:
'
强制退款(忽略余额检查)
'
,
orderCancelled
:
'
订单已取消
'
,
retry
:
'
重试
'
,
retrySuccess
:
'
重试成功
'
,
approveRefund
:
'
批准退款
'
,
retryRefund
:
'
重试退款
'
,
refundRequestInfo
:
'
退款申请信息
'
,
refundRequestedAt
:
'
申请时间
'
,
refundRequestedBy
:
'
申请人
'
,
refundRequestReason
:
'
申请原因
'
,
auditLogs
:
'
操作日志
'
,
operator
:
'
操作人
'
,
channelName
:
'
渠道名称
'
,
channelDescription
:
'
渠道描述
'
,
createChannel
:
'
创建渠道
'
,
editChannel
:
'
编辑渠道
'
,
deleteChannel
:
'
删除渠道
'
,
deleteChannelConfirm
:
'
确定要删除此渠道吗?
'
,
planName
:
'
套餐名称
'
,
planDescription
:
'
套餐描述
'
,
createPlan
:
'
创建套餐
'
,
editPlan
:
'
编辑套餐
'
,
deletePlan
:
'
删除套餐
'
,
deletePlanConfirm
:
'
确定要删除此套餐吗?
'
,
originalPrice
:
'
原价
'
,
price
:
'
价格
'
,
validityDays
:
'
有效期(天)
'
,
validityUnit
:
'
有效期单位
'
,
sortOrder
:
'
排序
'
,
forSale
:
'
上架状态
'
,
onSale
:
'
上架
'
,
offSale
:
'
下架
'
,
group
:
'
分组
'
,
groupId
:
'
分组 ID
'
,
features
:
'
功能特性
'
,
featuresHint
:
'
每行一个特性
'
,
featuresPlaceholder
:
'
输入套餐特性...
'
,
providerManagement
:
'
服务商管理
'
,
providerManagementDesc
:
'
管理支付服务商实例
'
,
createProvider
:
'
创建服务商
'
,
editProvider
:
'
编辑服务商
'
,
deleteProvider
:
'
删除服务商
'
,
deleteProviderConfirm
:
'
确定要删除此服务商吗?
'
,
providerName
:
'
服务商名称
'
,
providerKey
:
'
服务商标识
'
,
selectProviderKey
:
'
选择服务商标识
'
,
providerConfig
:
'
服务商配置
'
,
noProviders
:
'
暂无服务商
'
,
noProvidersHint
:
'
创建一个服务商实例以开始接受支付
'
,
supportedTypes
:
'
支持的支付方式
'
,
supportedTypesHint
:
'
选择此服务商支持的支付方式
'
,
rateMultiplier
:
'
费率倍数
'
,
dashboardTitle
:
'
支付概览
'
,
dashboardDesc
:
'
充值订单统计与分析
'
,
daySuffix
:
'
天
'
,
paymentConfigTitle
:
'
支付配置
'
,
paymentConfigDesc
:
'
管理支付服务商与相关设置
'
,
plansPageTitle
:
'
订阅套餐管理
'
,
plansPageDesc
:
'
管理订阅套餐配置
'
,
tabPlanConfig
:
'
套餐配置
'
,
tabUserSubs
:
'
用户订阅
'
,
selectGroup
:
'
请选择分组
'
,
groupMissing
:
'
缺失
'
,
groupInfo
:
'
分组信息
'
,
platform
:
'
平台
'
,
rateMultiplierLabel
:
'
倍率
'
,
dailyLimit
:
'
日限额
'
,
weeklyLimit
:
'
周限额
'
,
monthlyLimit
:
'
月限额
'
,
unlimited
:
'
无限制
'
,
searchUserSubs
:
'
搜索用户订阅...
'
,
daily
:
'
日
'
,
weekly
:
'
周
'
,
monthly
:
'
月
'
,
subsStatus
:
{
active
:
'
生效中
'
,
expired
:
'
已过期
'
,
revoked
:
'
已撤销
'
,
},
},
},
}
frontend/src/router/index.ts
View file @
97f14b7a
...
...
@@ -201,13 +201,73 @@ const routes: RouteRecordRaw[] = [
{
path
:
'
/purchase
'
,
name
:
'
PurchaseSubscription
'
,
component
:
()
=>
import
(
'
@/views/user/P
urchaseSubscription
View.vue
'
),
component
:
()
=>
import
(
'
@/views/user/P
ayment
View.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Purchase Subscription
'
,
titleKey
:
'
purchase.title
'
,
descriptionKey
:
'
purchase.description
'
titleKey
:
'
nav.buySubscription
'
,
descriptionKey
:
'
purchase.description
'
,
requiresPayment
:
true
}
},
{
path
:
'
/orders
'
,
name
:
'
OrderList
'
,
component
:
()
=>
import
(
'
@/views/user/UserOrdersView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
My Orders
'
,
titleKey
:
'
nav.myOrders
'
,
requiresPayment
:
true
}
},
{
path
:
'
/payment/qrcode
'
,
name
:
'
PaymentQRCode
'
,
component
:
()
=>
import
(
'
@/views/user/PaymentQRCodeView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Payment
'
,
titleKey
:
'
payment.qr.scanToPay
'
,
requiresPayment
:
true
}
},
{
path
:
'
/payment/result
'
,
name
:
'
PaymentResult
'
,
component
:
()
=>
import
(
'
@/views/user/PaymentResultView.vue
'
),
meta
:
{
requiresAuth
:
false
,
requiresAdmin
:
false
,
title
:
'
Payment Result
'
,
titleKey
:
'
payment.result.success
'
,
requiresPayment
:
false
}
},
{
path
:
'
/payment/stripe
'
,
name
:
'
StripePayment
'
,
component
:
()
=>
import
(
'
@/views/user/StripePaymentView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Stripe Payment
'
,
titleKey
:
'
payment.stripePay
'
,
requiresPayment
:
true
}
},
{
path
:
'
/payment/stripe-popup
'
,
name
:
'
StripePopup
'
,
component
:
()
=>
import
(
'
@/views/user/StripePopupView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Payment
'
,
requiresPayment
:
true
}
},
{
...
...
@@ -384,6 +444,45 @@ const routes: RouteRecordRaw[] = [
}
},
// ==================== Payment Admin Routes ====================
{
path
:
'
/admin/orders/dashboard
'
,
name
:
'
AdminPaymentDashboard
'
,
component
:
()
=>
import
(
'
@/views/admin/orders/AdminPaymentDashboardView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Payment Dashboard
'
,
titleKey
:
'
nav.paymentDashboard
'
,
requiresPayment
:
true
}
},
{
path
:
'
/admin/orders
'
,
name
:
'
AdminOrders
'
,
component
:
()
=>
import
(
'
@/views/admin/orders/AdminOrdersView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Order Management
'
,
titleKey
:
'
nav.orderManagement
'
,
requiresPayment
:
true
}
},
{
path
:
'
/admin/orders/plans
'
,
name
:
'
AdminPaymentPlans
'
,
component
:
()
=>
import
(
'
@/views/admin/orders/AdminPaymentPlansView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Subscription Plans
'
,
titleKey
:
'
nav.paymentPlans
'
,
requiresPayment
:
true
}
},
// ==================== 404 Not Found ====================
{
path
:
'
/:pathMatch(.*)*
'
,
...
...
@@ -500,6 +599,16 @@ router.beforeEach((to, _from, next) => {
return
}
// Check payment requirement (internal payment system only)
if
(
to
.
meta
.
requiresPayment
)
{
const
paymentEnabled
=
appStore
.
cachedPublicSettings
?.
payment_enabled
if
(
!
paymentEnabled
)
{
next
(
authStore
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
)
return
}
}
// 简易模式下限制访问某些页面
if
(
authStore
.
isSimpleMode
)
{
const
restrictedPaths
=
[
...
...
frontend/src/router/meta.d.ts
View file @
97f14b7a
...
...
@@ -42,5 +42,21 @@ declare module 'vue-router' {
* @default false
*/
hideInMenu
?:
boolean
/**
* Whether this route requires internal payment system to be enabled
* @default false
*/
requiresPayment
?:
boolean
/**
* i18n key for the page title
*/
titleKey
?:
string
/**
* i18n key for the page description
*/
descriptionKey
?:
string
}
}
frontend/src/stores/adminSettings.ts
View file @
97f14b7a
...
...
@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
const
opsMonitoringEnabled
=
ref
(
readCachedBool
(
'
ops_monitoring_enabled_cached
'
,
true
))
const
opsRealtimeMonitoringEnabled
=
ref
(
readCachedBool
(
'
ops_realtime_monitoring_enabled_cached
'
,
true
))
const
opsQueryModeDefault
=
ref
(
readCachedString
(
'
ops_query_mode_default_cached
'
,
'
auto
'
))
const
paymentEnabled
=
ref
(
readCachedBool
(
'
payment_enabled_cached
'
,
false
))
const
customMenuItems
=
ref
<
CustomMenuItem
[]
>
([])
async
function
fetch
(
force
=
false
):
Promise
<
void
>
{
...
...
@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loading
.
value
=
true
try
{
const
settings
=
await
adminAPI
.
settings
.
getSettings
()
const
[
settings
,
paymentConfigResp
]
=
await
Promise
.
all
([
adminAPI
.
settings
.
getSettings
(),
adminAPI
.
payment
.
getConfig
()
])
opsMonitoringEnabled
.
value
=
settings
.
ops_monitoring_enabled
??
true
writeCachedBool
(
'
ops_monitoring_enabled_cached
'
,
opsMonitoringEnabled
.
value
)
...
...
@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
customMenuItems
.
value
=
Array
.
isArray
(
settings
.
custom_menu_items
)
?
settings
.
custom_menu_items
:
[]
paymentEnabled
.
value
=
paymentConfigResp
.
data
?.
enabled
??
false
writeCachedBool
(
'
payment_enabled_cached
'
,
paymentEnabled
.
value
)
loaded
.
value
=
true
}
catch
(
err
)
{
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
...
...
@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loaded
.
value
=
true
}
function
setPaymentEnabledLocal
(
value
:
boolean
)
{
paymentEnabled
.
value
=
value
writeCachedBool
(
'
payment_enabled_cached
'
,
value
)
loaded
.
value
=
true
}
function
setOpsQueryModeDefaultLocal
(
value
:
string
)
{
opsQueryModeDefault
.
value
=
value
||
'
auto
'
writeCachedString
(
'
ops_query_mode_default_cached
'
,
opsQueryModeDefault
.
value
)
...
...
@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsMonitoringEnabled
,
opsRealtimeMonitoringEnabled
,
opsQueryModeDefault
,
paymentEnabled
,
customMenuItems
,
fetch
,
setOpsMonitoringEnabledLocal
,
setOpsRealtimeMonitoringEnabledLocal
,
setPaymentEnabledLocal
,
setOpsQueryModeDefaultLocal
}
})
frontend/src/stores/app.ts
View file @
97f14b7a
...
...
@@ -330,8 +330,7 @@ export const useAppStore = defineStore('app', () => {
doc_url
:
docUrl
.
value
,
home_content
:
''
,
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
payment_enabled
:
false
,
table_default_page_size
:
20
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[],
...
...
@@ -339,7 +338,6 @@ export const useAppStore = defineStore('app', () => {
linuxdo_oauth_enabled
:
false
,
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
...
...
frontend/src/stores/index.ts
View file @
97f14b7a
...
...
@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings'
export
{
useSubscriptionStore
}
from
'
./subscriptions
'
export
{
useOnboardingStore
}
from
'
./onboarding
'
export
{
useAnnouncementStore
}
from
'
./announcements
'
export
{
usePaymentStore
}
from
'
./payment
'
// Re-export types for convenience
export
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
...
...
frontend/src/stores/payment.ts
0 → 100644
View file @
97f14b7a
/**
* Payment Store
* Manages payment configuration, current order state, and subscription plans
*/
import
{
defineStore
}
from
'
pinia
'
import
{
ref
}
from
'
vue
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
type
{
PaymentConfig
,
PaymentOrder
,
SubscriptionPlan
,
CreateOrderRequest
}
from
'
@/types/payment
'
export
const
usePaymentStore
=
defineStore
(
'
payment
'
,
()
=>
{
// ==================== State ====================
/** Payment configuration from backend */
const
config
=
ref
<
PaymentConfig
|
null
>
(
null
)
/** Currently active order (for payment flow) */
const
currentOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
/** Available subscription plans */
const
plans
=
ref
<
SubscriptionPlan
[]
>
([])
const
configLoading
=
ref
(
false
)
const
configLoaded
=
ref
(
false
)
// ==================== Actions ====================
/** Fetch payment configuration */
async
function
fetchConfig
(
force
=
false
):
Promise
<
PaymentConfig
|
null
>
{
if
(
configLoaded
.
value
&&
!
force
)
return
config
.
value
if
(
configLoading
.
value
)
return
config
.
value
configLoading
.
value
=
true
try
{
const
response
=
await
paymentAPI
.
getConfig
()
config
.
value
=
response
.
data
configLoaded
.
value
=
true
return
config
.
value
}
catch
(
error
:
unknown
)
{
console
.
error
(
'
[payment] Failed to fetch config:
'
,
error
)
return
null
}
finally
{
configLoading
.
value
=
false
}
}
/** Fetch available subscription plans */
async
function
fetchPlans
():
Promise
<
SubscriptionPlan
[]
>
{
try
{
const
response
=
await
paymentAPI
.
getPlans
()
// Backend returns features as newline-separated string; parse to array
plans
.
value
=
(
response
.
data
||
[]).
map
((
p
:
Omit
<
SubscriptionPlan
,
'
features
'
>
&
{
features
:
string
|
string
[]
})
=>
({
...
p
,
features
:
typeof
p
.
features
===
'
string
'
?
p
.
features
.
split
(
'
\n
'
).
map
((
f
:
string
)
=>
f
.
trim
()).
filter
(
Boolean
)
:
(
p
.
features
||
[]),
}))
return
plans
.
value
}
catch
(
error
:
unknown
)
{
console
.
error
(
'
[payment] Failed to fetch plans:
'
,
error
)
return
[]
}
}
/** Create a new order and set it as current */
async
function
createOrder
(
params
:
CreateOrderRequest
)
{
const
response
=
await
paymentAPI
.
createOrder
(
params
)
return
response
.
data
}
/** Poll order status by ID */
async
function
pollOrderStatus
(
orderId
:
number
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
response
=
await
paymentAPI
.
getOrder
(
orderId
)
const
order
=
response
.
data
if
(
currentOrder
.
value
?.
id
===
orderId
)
{
currentOrder
.
value
=
order
}
return
order
}
catch
(
error
:
unknown
)
{
console
.
error
(
'
[payment] Failed to poll order status:
'
,
error
)
return
null
}
}
/** Clear current order state */
function
clearCurrentOrder
()
{
currentOrder
.
value
=
null
}
return
{
config
,
currentOrder
,
plans
,
configLoading
,
configLoaded
,
fetchConfig
,
fetchPlans
,
createOrder
,
pollOrderStatus
,
clearCurrentOrder
}
})
frontend/src/style.css
View file @
97f14b7a
...
...
@@ -114,6 +114,27 @@
@apply
dark
:
shadow-amber-500
/
20
;
}
.btn-stripe
{
@apply
bg-[#635bff]
text-white
shadow-md
shadow-[#635bff]/25;
@apply
hover
:
bg-
[
#5851ea
]
hover
:
shadow-lg
hover
:
shadow-
[
#635bff
]/
30
;
@apply
dark
:
bg-
[
#7a73ff
]
dark
:
shadow-
[
#7a73ff
]/
20
;
@apply
dark
:
hover
:
bg-
[
#635bff
];
}
.btn-alipay
{
@apply
bg-[#00AEEF]
text-white
shadow-md
shadow-[#00AEEF]/25;
@apply
hover
:
bg-
[
#009dd6
]
hover
:
shadow-lg
hover
:
shadow-
[
#00AEEF
]/
30
;
@apply
active
:
bg-
[
#008cbe
];
@apply
dark
:
shadow-
[
#00AEEF
]/
20
;
}
.btn-wxpay
{
@apply
bg-[#2BB741]
text-white
shadow-md
shadow-[#2BB741]/25;
@apply
hover
:
bg-
[
#24a038
]
hover
:
shadow-lg
hover
:
shadow-
[
#2BB741
]/
30
;
@apply
active
:
bg-
[
#1d8a2f
];
@apply
dark
:
shadow-
[
#2BB741
]/
20
;
}
.btn-sm
{
@apply
rounded-lg
px-3
py-1.5
text-xs;
}
...
...
frontend/src/types/index.ts
View file @
97f14b7a
...
...
@@ -104,8 +104,7 @@ export interface PublicSettings {
doc_url
:
string
home_content
:
string
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
payment_enabled
:
boolean
table_default_page_size
:
number
table_page_size_options
:
number
[]
custom_menu_items
:
CustomMenuItem
[]
...
...
@@ -113,7 +112,6 @@ export interface PublicSettings {
linuxdo_oauth_enabled
:
boolean
oidc_oauth_enabled
:
boolean
oidc_oauth_provider_name
:
string
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
@@ -1633,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest {
max_results
?:
number
auto_recover
?:
boolean
}
// Payment types
export
type
{
SubscriptionPlan
,
PaymentOrder
,
CheckoutInfoResponse
}
from
'
./payment
'
frontend/src/types/payment.ts
0 → 100644
View file @
97f14b7a
/**
* Payment System Type Definitions
*/
// ==================== Enums / Union Types ====================
export
type
OrderStatus
=
|
'
PENDING
'
|
'
PAID
'
|
'
RECHARGING
'
|
'
COMPLETED
'
|
'
EXPIRED
'
|
'
CANCELLED
'
|
'
FAILED
'
|
'
REFUND_REQUESTED
'
|
'
REFUNDING
'
|
'
PARTIALLY_REFUNDED
'
|
'
REFUNDED
'
|
'
REFUND_FAILED
'
export
type
PaymentType
=
'
alipay
'
|
'
wxpay
'
|
'
alipay_direct
'
|
'
wxpay_direct
'
|
'
stripe
'
|
'
easypay
'
export
type
OrderType
=
'
balance
'
|
'
subscription
'
// ==================== Configuration ====================
export
interface
PaymentConfig
{
payment_enabled
:
boolean
min_amount
:
number
max_amount
:
number
daily_limit
:
number
max_pending_orders
:
number
order_timeout_minutes
:
number
balance_disabled
:
boolean
enabled_payment_types
:
PaymentType
[]
help_image_url
:
string
help_text
:
string
stripe_publishable_key
:
string
}
export
interface
MethodLimit
{
daily_limit
:
number
daily_used
:
number
daily_remaining
:
number
single_min
:
number
single_max
:
number
fee_rate
:
number
available
:
boolean
}
/** Response from /payment/limits API */
export
interface
MethodLimitsResponse
{
methods
:
Record
<
string
,
MethodLimit
>
global_min
:
number
// widest min across all methods; 0 = no minimum
global_max
:
number
// widest max across all methods; 0 = no maximum
}
/** Response from /payment/checkout-info API — single call for the payment page */
export
interface
CheckoutInfoResponse
{
methods
:
Record
<
string
,
MethodLimit
>
global_min
:
number
global_max
:
number
plans
:
SubscriptionPlan
[]
balance_disabled
:
boolean
help_text
:
string
help_image_url
:
string
stripe_publishable_key
:
string
}
// ==================== Orders ====================
export
interface
PaymentOrder
{
id
:
number
user_id
:
number
amount
:
number
pay_amount
:
number
fee_rate
:
number
payment_type
:
string
out_trade_no
:
string
status
:
OrderStatus
order_type
:
OrderType
created_at
:
string
expires_at
:
string
paid_at
?:
string
completed_at
?:
string
refund_amount
:
number
refund_reason
?:
string
refund_requested_at
?:
string
refund_requested_by
?:
number
refund_request_reason
?:
string
plan_id
?:
number
}
// ==================== Plans & Channels ====================
export
interface
SubscriptionPlan
{
id
:
number
group_id
:
number
group_platform
?:
string
group_name
?:
string
rate_multiplier
?:
number
daily_limit_usd
?:
number
|
null
weekly_limit_usd
?:
number
|
null
monthly_limit_usd
?:
number
|
null
supported_model_scopes
?:
string
[]
name
:
string
description
:
string
price
:
number
original_price
?:
number
validity_days
:
number
validity_unit
:
string
/** Stored as JSON string in backend; API layer should parse before use */
features
:
string
[]
for_sale
:
boolean
sort_order
:
number
}
export
interface
PaymentChannel
{
id
:
number
group_id
?:
number
name
:
string
platform
:
string
rate_multiplier
:
number
description
:
string
models
:
string
[]
features
:
string
[]
enabled
:
boolean
}
// ==================== Providers ====================
export
interface
ProviderInstance
{
id
:
number
provider_key
:
string
name
:
string
config
:
Record
<
string
,
string
>
supported_types
:
string
[]
enabled
:
boolean
payment_mode
:
string
refund_enabled
:
boolean
limits
:
string
sort_order
:
number
}
// ==================== Request / Response ====================
export
interface
CreateOrderRequest
{
amount
:
number
payment_type
:
string
order_type
:
string
plan_id
?:
number
}
export
interface
CreateOrderResult
{
order_id
:
number
pay_url
?:
string
qr_code
?:
string
client_secret
?:
string
pay_amount
:
number
expires_at
:
string
payment_mode
?:
string
}
export
interface
DashboardStats
{
today_amount
:
number
total_amount
:
number
today_count
:
number
total_count
:
number
avg_amount
:
number
daily_series
:
{
date
:
string
;
amount
:
number
;
count
:
number
}[]
payment_methods
:
{
type
:
string
;
amount
:
number
;
count
:
number
}[]
top_users
:
{
user_id
:
number
;
email
:
string
;
amount
:
number
}[]
}
frontend/src/utils/apiError.ts
0 → 100644
View file @
97f14b7a
/**
* Centralized API error message extraction
*
* The API client interceptor rejects with a plain object: { status, code, message, error }
* This utility extracts the user-facing message from any error shape.
*/
interface
ApiErrorLike
{
status
?:
number
code
?:
number
|
string
message
?:
string
error
?:
string
reason
?:
string
metadata
?:
Record
<
string
,
unknown
>
response
?:
{
data
?:
{
detail
?:
string
message
?:
string
code
?:
number
|
string
}
}
}
/**
* Extract the error code from an API error object.
*/
export
function
extractApiErrorCode
(
err
:
unknown
):
string
|
undefined
{
if
(
!
err
||
typeof
err
!==
'
object
'
)
return
undefined
const
e
=
err
as
ApiErrorLike
const
code
=
e
.
code
??
e
.
reason
??
e
.
response
?.
data
?.
code
return
code
!=
null
?
String
(
code
)
:
undefined
}
/**
* Extract a displayable error message from an API error.
*
* @param err - The caught error (unknown type)
* @param fallback - Fallback message if none can be extracted (use t('common.error') or similar)
* @param i18nMap - Optional map of error codes to i18n translated strings
*/
export
function
extractApiErrorMessage
(
err
:
unknown
,
fallback
=
'
Unknown error
'
,
i18nMap
?:
Record
<
string
,
string
>
,
):
string
{
if
(
!
err
)
return
fallback
// Try i18n mapping by error code first
if
(
i18nMap
)
{
const
code
=
extractApiErrorCode
(
err
)
if
(
code
&&
i18nMap
[
code
])
return
i18nMap
[
code
]
}
// Plain object from API client interceptor (most common case)
if
(
typeof
err
===
'
object
'
&&
err
!==
null
)
{
const
e
=
err
as
ApiErrorLike
// Interceptor shape: { message, error }
if
(
e
.
message
)
return
e
.
message
if
(
e
.
error
)
return
e
.
error
// Legacy axios shape: { response.data.detail }
if
(
e
.
response
?.
data
?.
detail
)
return
e
.
response
.
data
.
detail
if
(
e
.
response
?.
data
?.
message
)
return
e
.
response
.
data
.
message
}
// Standard Error
if
(
err
instanceof
Error
)
return
err
.
message
// Last resort
const
str
=
String
(
err
)
return
str
===
'
[object Object]
'
?
fallback
:
str
}
Prev
1
…
4
5
6
7
8
9
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