@@ -0,0 +1,379 @@
import React , { useState , useEffect , useCallback } from 'react'
import { View , Text } from '@tarojs/components'
import { Button , Cell , Price , Divider } from '@nutui/nutui-react-taro'
import { Check , Close , Tips , Clock } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { request } from '@/utils/request'
import { getOpenId , loginByOpenId } from '@/api/passport/wx-login'
import { saveStorageByLoginUser } from '@/utils/server'
import { TenantId } from '@/config/app'
/**
* 小程序支付页面
* 接收参数 subscriptionNo,完成 JSAPI 支付
*/
interface SubscriptionDetail {
id : number
subscriptionNo : string
productId : number
productName : string
productLogo? : string
productIcon? : string
status : string
priceType : string
payPrice : number
subscriptionPeriod? : string
}
interface JsapiPayParams {
timeStamp : string
nonceStr : string
package : string
signType : string
paySign : string
outTradeNo : string
}
const PayPage : React.FC = ( ) = > {
const [ loading , setLoading ] = useState ( true )
const [ paying , setPaying ] = useState ( false )
const [ detail , setDetail ] = useState < SubscriptionDetail | null > ( null )
const [ errorMsg , setErrorMsg ] = useState ( '' )
const [ paid , setPaid ] = useState ( false )
// 从页面参数中获取 subscriptionNo
// 小程序码 scene 会作为 query.scene 传入,需解码
const getSubscriptionNo = useCallback ( ( ) = > {
const instance = Taro . getCurrentInstance ( )
const params = instance ? . router ? . params || { }
console . log ( '[PayPage] ===== 参数调试开始 =====' )
console . log ( '[PayPage] 原始 params:' , JSON . stringify ( params ) )
console . log ( '[PayPage] params.scene:' , params . scene )
console . log ( '[PayPage] params.subscriptionNo:' , params . subscriptionNo )
// 优先读取 scene(小程序码参数),再读取 subscriptionNo(普通链接参数)
const scene = params . scene ? decodeURIComponent ( params . scene ) : ''
const subscriptionNo = scene || params . subscriptionNo || ''
console . log ( '[PayPage] decodeURIComponent(scene):' , scene )
console . log ( '[PayPage] 最终 subscriptionNo:' , subscriptionNo )
console . log ( '[PayPage] ===== 参数调试结束 =====' )
return subscriptionNo
} , [ ] )
// 获取订阅详情
const fetchDetail = useCallback ( async ( ) = > {
const subscriptionNo = getSubscriptionNo ( )
if ( ! subscriptionNo ) {
console . error ( '[PayPage] subscriptionNo 为空,无法请求' )
setErrorMsg ( '缺少订单参数' )
setLoading ( false )
return
}
console . log ( '[PayPage] ===== 请求调试开始 =====' )
console . log ( '[PayPage] 请求 URL:' , ` /app/subscription/detail-by-no/ ${ subscriptionNo } ` )
console . log ( '[PayPage] 当前 token:' , Taro . getStorageSync ( 'access_token' ) || '(无token)' )
try {
// request 默认 returnRaw=false,拦截器已拆包,返回的就是 data 部分(订阅对象)
const data : any = await request ( {
url : ` /app/subscription/detail-by-no/ ${ subscriptionNo } ` ,
method : 'GET' ,
returnRaw : true // 先用 returnRaw=true 拿到完整响应,方便调试
} )
console . log ( '[PayPage] 接口完整返回(returnRaw=true) :' , JSON . stringify ( data ) )
console . log ( '[PayPage] data.code:' , data ? . code )
console . log ( '[PayPage] data.message:' , data ? . message )
console . log ( '[PayPage] data.data:' , data ? . data )
// 判断业务状态码
if ( data ? . code === 0 || data ? . code === 200 ) {
const subscription = data . data || data
console . log ( '[PayPage] 订阅对象:' , JSON . stringify ( subscription ) )
console . log ( '[PayPage] 订阅状态:' , subscription ? . status )
if ( subscription ? . status === 'active' ) {
setPaid ( true )
setDetail ( subscription )
} else {
setDetail ( subscription )
}
} else {
console . error ( '[PayPage] 业务错误 code:' , data ? . code , 'message:' , data ? . message )
setErrorMsg ( data ? . message || ` 业务错误(code= ${ data ? . code } ) ` )
}
} catch ( err : any ) {
console . error ( '[PayPage] ===== 请求异常 =====' )
console . error ( '[PayPage] err.name:' , err ? . name )
console . error ( '[PayPage] err.type:' , err ? . type )
console . error ( '[PayPage] err.code:' , err ? . code )
console . error ( '[PayPage] err.message:' , err ? . message )
console . error ( '[PayPage] err.data:' , err ? . data )
console . error ( '[PayPage] err 完整:' , JSON . stringify ( err ) )
setErrorMsg ( err ? . message || '网络异常,请重试' )
} finally {
setLoading ( false )
console . log ( '[PayPage] ===== 请求调试结束 =====' )
}
} , [ getSubscriptionNo ] )
useEffect ( ( ) = > {
fetchDetail ( )
} , [ fetchDetail ] )
// 执行支付
const handlePay = async ( ) = > {
if ( ! detail ) return
setPaying ( true )
try {
// 1. 获取微信登录凭证(code)
const loginRes = await Taro . login ( )
if ( ! loginRes . code ) {
throw new Error ( '获取登录凭证失败' )
}
console . log ( '[PayPage] Taro.login code:' , loginRes . code )
// 2. 用 code 换取 openid(关键步骤,之前遗漏了)
const openIdRes = await getOpenId ( loginRes . code )
console . log ( '[PayPage] getOpenId 结果:' , openIdRes )
if ( ! openIdRes . success || ! openIdRes . openid ) {
throw new Error ( openIdRes . message || '获取 openid 失败,请重试' )
}
const openid = openIdRes . openid
// 缓存 openid,后续支付确认等接口可用
Taro . setStorageSync ( 'openid' , openid )
console . log ( '[PayPage] 获取到 openid:' , openid )
// 3. 尝试自动登录(获取 token,mp-prepay 接口需要登录态)
// 如果用户已在小程序注册过,loginByOpenId 会返回 token
// 如果用户未注册,不影响 openid 已获取,但 mp-prepay 可能需要特殊处理
try {
const loginResult = await loginByOpenId ( {
code : loginRes.code ,
tenantId : TenantId
} )
console . log ( '[PayPage] loginByOpenId 结果:' , loginResult )
if ( loginResult . success && loginResult . data ) {
// 登录成功,保存 token
saveStorageByLoginUser ( loginResult . data . access_token || '' , loginResult . data . user )
console . log ( '[PayPage] 自动登录成功,已保存 token' )
} else {
console . warn ( '[PayPage] 自动登录失败:' , loginResult . message )
// 未注册用户:openid 已经有了,但 mp-prepay 需要 userId
// 提示用户先注册/登录
if ( loginResult . message ? . includes ( '未注册' ) || loginResult . message ? . includes ( '不存在' ) ) {
Taro . showModal ( {
title : '需要先登录' ,
content : '支付前需要先注册账号,是否前往登录?' ,
confirmText : '去登录' ,
cancelText : '取消'
} ) . then ( modalRes = > {
if ( modalRes . confirm ) {
Taro . navigateTo ( { url : '/passport/login' } )
}
} )
return // 不继续支付流程
}
}
} catch ( loginErr ) {
console . warn ( '[PayPage] 自动登录异常:' , loginErr )
// 登录异常但不阻断支付,openid 已拿到,尝试继续
}
// 4. 请求后端创建 JSAPI 预支付订单
const prepayRes : any = await request ( {
url : ` /app/subscription/mp-prepay/ ${ detail . id } ` ,
method : 'POST' ,
data : { openid } ,
returnRaw : true
} )
console . log ( '[PayPage] mp-prepay 完整返回:' , JSON . stringify ( prepayRes ) )
if ( prepayRes ? . code !== 0 && prepayRes ? . code !== 200 ) {
throw new Error ( prepayRes ? . message || '创建支付订单失败' )
}
const payParams : JsapiPayParams = prepayRes . data || prepayRes
if ( ! payParams . timeStamp || ! payParams . package || ! payParams . paySign ) {
throw new Error ( '支付参数不完整' )
}
// 5. 调用微信支付
await Taro . requestPayment ( {
timeStamp : payParams.timeStamp ,
nonceStr : payParams.nonceStr ,
package : payParams . package ,
signType : ( payParams . signType || 'RSA' ) as any ,
paySign : payParams.paySign
} )
// 6. 支付成功后通知后端确认
try {
await request ( {
url : ` /app/subscription/mp-confirm/ ${ detail . subscriptionNo } ` ,
method : 'POST' ,
data : { }
} )
} catch ( confirmErr ) {
console . warn ( '支付确认通知失败,等待轮询:' , confirmErr )
}
// 7. 显示成功状态
setPaid ( true )
Taro . showToast ( { title : '支付成功' , icon : 'success' } )
// 8. 延迟自动跳转到已购产品页面
setTimeout ( ( ) = > {
Taro . navigateTo ( { url : '/user/apps/index' } ) . catch ( ( ) = > {
// 如果 navigateTo 失败(比如在 tab 页),回退到用户页
Taro . switchTab ( { url : '/pages/user/user' } )
} )
} , 1500 )
} catch ( err : any ) {
console . error ( '[PayPage] 支付失败:' , err )
console . error ( '[PayPage] err.name:' , err ? . name , 'err.type:' , err ? . type )
console . error ( '[PayPage] err.code:' , err ? . code , 'err.message:' , err ? . message )
if ( err ? . errMsg ? . includes ( 'cancel' ) ) {
Taro . showToast ( { title : '支付已取消' , icon : 'none' } )
} else {
const msg = err ? . message || err ? . errMsg || '支付失败,请重试'
Taro . showToast ( { title : msg , icon : 'error' , duration : 2000 } )
}
} finally {
setPaying ( false )
}
}
// 返回
const handleBack = ( ) = > {
Taro . navigateBack ( { delta : 1 } ) . catch ( ( ) = > {
Taro . switchTab ( { url : '/pages/user/user' } )
} )
}
// 格式化价格描述
const getPriceDesc = ( ) = > {
if ( ! detail ) return ''
if ( detail . priceType === 'one_time' ) return '永久买断'
if ( detail . priceType === 'subscription' ) {
return detail . subscriptionPeriod === 'year' ? '按年订阅' : '按月订阅'
}
return ''
}
// --- 加载中 ---
if ( loading ) {
return (
< View className = "pay-page min-h-screen bg-gray-50 flex items-center justify-center" >
< View className = "text-center" >
< Clock className = "text-blue-500 mb-4" size = "48" / >
< Text className = "block text-gray-500" > 加 载 订 单 信 息 . . . < / Text >
< / View >
< / View >
)
}
// --- 错误 ---
if ( errorMsg ) {
return (
< View className = "pay-page min-h-screen bg-gray-50 flex items-center justify-center" >
< View className = "text-center p-8" >
< Close className = "text-red-500 mb-4" size = "48" / >
< Text className = "block text-gray-800 text-lg mb-2" > 获 取 订 单 失 败 < / Text >
< Text className = "block text-gray-500 mb-6" > { errorMsg } < / Text >
< Button type = "primary" onClick = { handleBack } > 返 回 < / Button >
< / View >
< / View >
)
}
// --- 已支付 ---
if ( paid ) {
return (
< View className = "pay-page min-h-screen bg-gray-50 flex items-center justify-center" >
< View className = "text-center p-8" >
< View className = "w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4" >
< Check className = "text-green-500" size = "40" / >
< / View >
< Text className = "block text-green-600 text-xl font-bold mb-2" > 支 付 成 功 < / Text >
< Text className = "block text-gray-500 mb-2" > { detail ? . productName || '应用订阅' } < / Text >
{ detail ? . payPrice ? (
< Text className = "block text-gray-400 mb-6" >
已 支 付 ¥ { Number ( detail . payPrice ) . toFixed ( 2 ) }
< / Text >
) : null }
< Button type = "primary" onClick = { handleBack } > 完 成 < / Button >
< / View >
< / View >
)
}
// --- 支付确认 ---
return (
< View className = "pay-page min-h-screen bg-gray-50" >
< View className = "p-4" >
{ /* 订单信息 */ }
< View className = "bg-white rounded-lg shadow-sm p-4 mb-4" >
< Text className = "block text-lg font-bold text-gray-800 mb-3" > 确 认 支 付 < / Text >
< Cell title = "商品名称" description = { detail ? . productName || '-' } / >
< Cell title = "价格类型" description = { getPriceDesc ( ) } / >
< View className = "flex items-center justify-between px-4 py-3" >
< Text className = "text-gray-600" > 支 付 金 额 < / Text >
< Price
price = { detail ? . payPrice }
size = "large"
thousands
/ >
< / View >
< Divider / >
< View className = "flex items-start px-4 py-2" >
< Tips className = "text-orange-500 mr-2 mt-1" size = "16" / >
< Text className = "text-sm text-gray-500" >
支 付 成 功 后 即 可 在 「 已 购 产 品 」 中 使 用 该 应 用
< / Text >
< / View >
< / View >
{ /* 支付按钮 */ }
< View className = "fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100" >
< View className = "flex gap-3" >
< Button
block
onClick = { handleBack }
className = "flex-1"
>
取 消
< / Button >
< Button
type = "primary"
block
loading = { paying }
onClick = { handlePay }
className = "flex-1"
>
{ paying ? '支付中...' : '立即支付' }
< / Button >
< / View >
< / View >
< / View >
{ /* 底部安全距离占位 */ }
< View style = { { height : '100px' } } / >
< / View >
)
}
export default PayPage