Files
websopy-taro/src/passport/pay/index.tsx
赵忠林 1b21f5e5b1 feat(pay-bak): 新增小程序支付页面及相关配置
- 新增确认支付页面配置,设置导航栏标题和样式
- 实现小程序支付页完整组件逻辑,包括加载订单、发起JSAPI支付等
- 支持通过 scene 或 subscriptionNo 获取订单参数
- 完善支付流程接口请求及错误处理,支持自动登录和支付确认
- 增加支付成功、支付中、支付失败、取消等状态页面展示
- 支持支付成功后跳转已购产品页或用户页
- 提供取消支付返回逻辑和界面展示
- 细化支付金额显示和提示用户支付后可使用应用功能
2026-07-16 00:06:03 +08:00

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 支付
*
* 重要:判断订单是否已支付,必须用 payStatus0=未支付 1=已支付),
* 不能用 status订阅生命周期active/pending/expired/cancelled
* 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0
* 若用 status 判支付会直接误判为"已支付"。
*/
interface SubscriptionDetail {
id: number
subscriptionNo: string
productId: number
productName: string
productLogo?: string
productIcon?: string
status: string
// 支付状态: 0-未支付 1-已支付
payStatus?: number
payTime?: string
transactionId?: 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] 订阅状态 status:', subscription?.status)
console.log('[PayPage] 支付状态 payStatus:', subscription?.payStatus)
console.log('[PayPage] 支付时间 payTime:', subscription?.payTime)
// 关键修复:判断是否已支付,必须用 payStatus 而非 status
// 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0
const isPaid = subscription?.payStatus === 1
const isCancelled = subscription?.status === 'cancelled'
const isExpired = subscription?.status === 'expired'
if (isCancelled) {
setErrorMsg('订单已取消,无法继续支付')
} else if (isExpired) {
setErrorMsg('订单已过期,请重新下单')
} else if (isPaid) {
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. 尝试自动登录(获取 tokenmp-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