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
a13ae5a0
Commit
a13ae5a0
authored
Apr 21, 2026
by
IanShaw027
Browse files
Fix mobile payment launch detection
parent
e4cfcae6
Changes
5
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
View file @
a13ae5a0
...
...
@@ -106,6 +106,35 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-2
'
)
})
it
(
'
prefers redirect on mobile when both pay_url and qr_code are present
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
pay_url
:
'
https://pay.example.com/mobile/session
'
,
qr_code
:
'
https://pay.example.com/qr/session
'
,
}),
{
visibleMethod
:
'
alipay
'
,
orderType
:
'
balance
'
,
isMobile
:
true
,
})
expect
(
decision
.
kind
).
toBe
(
'
redirect_waiting
'
)
expect
(
decision
.
paymentState
.
payUrl
).
toBe
(
'
https://pay.example.com/mobile/session
'
)
expect
(
decision
.
paymentState
.
qrCode
).
toBe
(
'
https://pay.example.com/qr/session
'
)
})
it
(
'
keeps QR flow on desktop when both pay_url and qr_code are present
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
pay_url
:
'
https://pay.example.com/desktop/session
'
,
qr_code
:
'
https://pay.example.com/qr/session
'
,
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
balance
'
,
isMobile
:
false
,
})
expect
(
decision
.
kind
).
toBe
(
'
qr_waiting
'
)
expect
(
decision
.
paymentState
.
qrCode
).
toBe
(
'
https://pay.example.com/qr/session
'
)
})
it
(
'
returns wechat oauth launch when backend requires in-app authorization
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
result_type
:
'
oauth_required
'
,
...
...
frontend/src/components/payment/paymentFlow.ts
View file @
a13ae5a0
...
...
@@ -46,6 +46,7 @@ export interface PaymentLaunchContext {
visibleMethod
:
string
orderType
:
OrderType
isMobile
:
boolean
isWechatBrowser
?:
boolean
now
?:
number
stripePopupUrl
?:
string
stripeRouteUrl
?:
string
...
...
@@ -159,7 +160,23 @@ export function decidePaymentLaunch(
return
{
kind
:
'
wechat_jsapi
'
,
paymentState
:
baseState
,
recovery
:
baseState
,
jsapi
:
jsapiPayload
}
}
if
(
baseState
.
qrCode
)
{
const
normalizedPaymentMode
=
baseState
.
paymentMode
.
trim
().
toLowerCase
()
const
prefersRedirect
=
normalizedPaymentMode
===
'
redirect
'
||
normalizedPaymentMode
===
'
popup
'
||
(
context
.
isMobile
&&
!!
baseState
.
payUrl
)
const
prefersQr
=
normalizedPaymentMode
===
'
qrcode
'
||
normalizedPaymentMode
===
'
native
'
||
(
!
prefersRedirect
&&
!!
baseState
.
qrCode
)
if
(
visibleMethod
===
'
wxpay
'
&&
context
.
isWechatBrowser
&&
baseState
.
payUrl
&&
!
baseState
.
qrCode
)
{
return
{
kind
:
'
redirect_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
if
(
prefersRedirect
&&
baseState
.
payUrl
)
{
return
{
kind
:
'
redirect_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
if
(
prefersQr
&&
baseState
.
qrCode
)
{
return
{
kind
:
'
qr_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
...
...
frontend/src/utils/__tests__/device.spec.ts
0 → 100644
View file @
a13ae5a0
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
detectMobileDevice
}
from
'
../device
'
describe
(
'
detectMobileDevice
'
,
()
=>
{
it
(
'
prefers userAgentData.mobile when available
'
,
()
=>
{
expect
(
detectMobileDevice
({
navigator
:
{
userAgent
:
'
Mozilla/5.0
'
,
userAgentData
:
{
mobile
:
true
},
},
})).
toBe
(
true
)
})
it
(
'
recognizes handheld browsers from the mobile UA token
'
,
()
=>
{
expect
(
detectMobileDevice
({
navigator
:
{
userAgent
:
'
Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/136.0 Mobile Safari/537.36
'
,
maxTouchPoints
:
5
,
},
})).
toBe
(
true
)
})
it
(
'
recognizes iPadOS desktop mode via touch capability
'
,
()
=>
{
expect
(
detectMobileDevice
({
navigator
:
{
userAgent
:
'
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15
'
,
platform
:
'
MacIntel
'
,
maxTouchPoints
:
5
,
},
})).
toBe
(
true
)
})
it
(
'
falls back to input capability detection for touch-first devices
'
,
()
=>
{
expect
(
detectMobileDevice
({
navigator
:
{
userAgent
:
'
Mozilla/5.0
'
,
maxTouchPoints
:
10
,
},
matchMedia
:
(
query
)
=>
({
matches
:
query
===
'
(pointer: coarse)
'
||
query
===
'
(hover: none)
'
,
}),
})).
toBe
(
true
)
})
it
(
'
keeps desktop environments as non-mobile
'
,
()
=>
{
expect
(
detectMobileDevice
({
navigator
:
{
userAgent
:
'
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0 Safari/537.36
'
,
platform
:
'
MacIntel
'
,
maxTouchPoints
:
0
,
},
matchMedia
:
()
=>
({
matches
:
false
}),
})).
toBe
(
false
)
})
})
frontend/src/utils/device.ts
View file @
a13ae5a0
/**
* Detect whether the current device is mobile.
* Uses navigator.userAgentData (modern API) with UA regex fallback.
*/
export
function
isMobileDevice
():
boolean
{
const
nav
=
navigator
as
unknown
as
Record
<
string
,
unknown
>
if
(
nav
.
userAgentData
&&
typeof
(
nav
.
userAgentData
as
Record
<
string
,
unknown
>
).
mobile
===
'
boolean
'
)
{
return
(
nav
.
userAgentData
as
Record
<
string
,
unknown
>
).
mobile
as
boolean
interface
NavigatorUADataLike
{
mobile
?:
boolean
}
interface
NavigatorLike
{
userAgent
?:
string
platform
?:
string
maxTouchPoints
?:
number
userAgentData
?:
NavigatorUADataLike
}
interface
MediaQueryResultLike
{
matches
:
boolean
}
interface
DeviceDetectionEnvironment
{
navigator
?:
NavigatorLike
matchMedia
?:
(
query
:
string
)
=>
MediaQueryResultLike
|
null
|
undefined
}
const
MOBILE_UA_RE
=
/
\b(
Mobi|Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|IEMobile
)\b
/i
const
TABLET_UA_RE
=
/
\b(
iPad|Tablet
)\b
/i
function
matchesQuery
(
matchMedia
:
DeviceDetectionEnvironment
[
'
matchMedia
'
],
query
:
string
,
):
boolean
{
try
{
return
matchMedia
?.(
query
)?.
matches
===
true
}
catch
{
return
false
}
return
/Android|iPhone|iPad|iPod|Mobile/i
.
test
(
navigator
.
userAgent
)
}
export
function
detectMobileDevice
(
env
:
DeviceDetectionEnvironment
=
{}):
boolean
{
const
nav
=
env
.
navigator
if
(
!
nav
)
return
false
if
(
nav
.
userAgentData
?.
mobile
===
true
)
{
return
true
}
const
userAgent
=
nav
.
userAgent
||
''
const
maxTouchPoints
=
nav
.
maxTouchPoints
??
0
const
isIPadOSDesktopMode
=
nav
.
platform
===
'
MacIntel
'
&&
maxTouchPoints
>
1
const
isMobileUA
=
MOBILE_UA_RE
.
test
(
userAgent
)
const
isTabletUA
=
TABLET_UA_RE
.
test
(
userAgent
)
||
isIPadOSDesktopMode
const
coarsePointer
=
matchesQuery
(
env
.
matchMedia
,
'
(pointer: coarse)
'
)
const
noHover
=
matchesQuery
(
env
.
matchMedia
,
'
(hover: none)
'
)
const
hasTouch
=
maxTouchPoints
>
0
return
isMobileUA
||
isTabletUA
||
(
coarsePointer
&&
noHover
&&
hasTouch
)
}
export
function
isMobileDevice
():
boolean
{
if
(
typeof
navigator
===
'
undefined
'
)
return
false
return
detectMobileDevice
({
navigator
,
matchMedia
:
typeof
window
!==
'
undefined
'
?
window
.
matchMedia
.
bind
(
window
)
:
undefined
,
})
}
frontend/src/views/user/PaymentView.vue
View file @
a13ae5a0
...
...
@@ -627,7 +627,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
if
(
options
.
wechatResumeToken
)
{
payload
.
wechat_resume_token
=
options
.
wechatResumeToken
}
payload
.
is_mobile
=
isMobileDevice
()
const
result
=
await
paymentStore
.
createOrder
(
payload
)
as
CreateOrderResult
&
{
resume_token
?:
string
}
const
openWindow
=
(
url
:
string
,
features
=
POPUP_WINDOW_FEATURES
)
=>
{
...
...
@@ -653,6 +652,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
visibleMethod
,
orderType
,
isMobile
:
isMobileDevice
(),
isWechatBrowser
:
typeof
window
!==
'
undefined
'
&&
/MicroMessenger/i
.
test
(
window
.
navigator
.
userAgent
),
stripePopupUrl
:
stripeRouteUrl
,
stripeRouteUrl
,
}
)
...
...
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