- 新增支付页面组件,实现JSAPI支付流程和多状态页面展示 - 配置支付页面导航条样式和标题 - app.config.ts中添加支付页面路径 - app.tsx中新增微信隐私协议授权弹窗,拦截敏感API授权流程 - api/system/file中导出ensurePrivacyAuthorized用于隐私授权预检 - 登录页和注册页新增useDidShow钩子调用ensurePrivacyAuthorized预检隐私协议 - 登录和注册接口请求迁移至统一request封装,修正响应解构 - 支付流程中新增获取openid和自动登录逻辑,支持未注册用户跳转登录 - 支付成功后通知后端确认并自动跳转到已购产品页面 - 优化支付状态判断逻辑,正确识别支付和订单状态 - 添加错误处理和用户提示,提升支付体验安全性与稳定性
320 lines
9.9 KiB
TypeScript
320 lines
9.9 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import Taro, { useDidShow } from '@tarojs/taro'
|
||
import { View, Text } from '@tarojs/components'
|
||
import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
||
import { TenantId } from '@/config/app'
|
||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||
import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
|
||
import { isUserDisabled } from '@/utils/auth'
|
||
import request from '@/utils/request'
|
||
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||
import {
|
||
getStoredInviteParams,
|
||
parseInviteParams,
|
||
saveInviteParams,
|
||
trackInviteSource,
|
||
checkAndHandleInviteRelation,
|
||
} from '@/utils/invite'
|
||
|
||
interface GetPhoneNumberDetail {
|
||
code?: string
|
||
encryptedData?: string
|
||
iv?: string
|
||
errMsg?: string
|
||
}
|
||
|
||
interface GetPhoneNumberEvent {
|
||
detail: GetPhoneNumberDetail
|
||
}
|
||
|
||
async function getWeappLoginCode(): Promise<string | undefined> {
|
||
try {
|
||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||
Taro.login({
|
||
success: (r) => resolve(r as any),
|
||
fail: (e) => reject(e),
|
||
})
|
||
})
|
||
return res?.code
|
||
} catch (_e) {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
async function ensureWxOpenIdSaved(opts: { user?: any; wxLoginCode?: string }) {
|
||
// JSAPI 微信支付必须有 openid;注册/登录后立刻补齐,避免后续创建支付单失败。
|
||
try {
|
||
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return
|
||
} catch (_e) {
|
||
if (process.env.TARO_ENV !== 'weapp') return
|
||
}
|
||
|
||
if (opts.user?.openid) return
|
||
|
||
const code = opts.wxLoginCode || (await getWeappLoginCode())
|
||
if (!code) return
|
||
|
||
// 该接口一般会在服务端把 openid 绑定到当前登录用户;返回值并不一定包含 openid。
|
||
await getWxOpenId({ code })
|
||
|
||
// 同步本地 User(让后续页面/逻辑能直接读到 openid)
|
||
try {
|
||
const fresh = await getUserInfo()
|
||
if (fresh) Taro.setStorageSync('User', fresh)
|
||
} catch (_e) {
|
||
// ignore: openid 已在服务端绑定,本地不同步也不影响后端创建支付订单
|
||
}
|
||
}
|
||
|
||
function safeDecodeMaybeEncoded(input?: string): string {
|
||
if (!input) return ''
|
||
try {
|
||
// Taro 路由参数通常是 URL 编码过的字符串
|
||
return decodeURIComponent(input)
|
||
} catch (_e) {
|
||
return input
|
||
}
|
||
}
|
||
|
||
function isTabBarUrl(url: string) {
|
||
const pure = url.split('?')[0]
|
||
return (
|
||
pure === '/pages/index/index' ||
|
||
pure === '/pages/shop/index' ||
|
||
pure === '/pages/shop/cart' ||
|
||
pure === '/pages/user/user'
|
||
)
|
||
}
|
||
|
||
const Register = () => {
|
||
const [isAgree, setIsAgree] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
// 短信验证码登录仅在非微信小程序端展示
|
||
const isWeapp = useMemo(() => {
|
||
try {
|
||
return Taro.getEnv() === Taro.ENV_TYPE.WEAPP
|
||
} catch (_e) {
|
||
return process.env.TARO_ENV === 'weapp'
|
||
}
|
||
}, [])
|
||
|
||
const router = Taro.getCurrentInstance().router
|
||
|
||
/** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */
|
||
useDidShow(() => {
|
||
if (!isWeapp) return
|
||
ensurePrivacyAuthorized().catch((e) => {
|
||
console.warn('注册页隐私协议预检失败:', e)
|
||
})
|
||
})
|
||
|
||
const redirectUrl = useMemo(() => {
|
||
const raw = (router?.params as any)?.redirect as string | undefined
|
||
const decoded = safeDecodeMaybeEncoded(raw)
|
||
if (!decoded) return ''
|
||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||
}, [router?.params])
|
||
|
||
// 如果从分享/二维码直接进入注册页(携带 inviter/source/t),先暂存邀请信息
|
||
useEffect(() => {
|
||
try {
|
||
const inviteParams = parseInviteParams({ query: router?.params })
|
||
if (inviteParams?.inviter) {
|
||
saveInviteParams(inviteParams)
|
||
trackInviteSource(inviteParams.source || 'qrcode', parseInt(inviteParams.inviter, 10))
|
||
}
|
||
} catch (e) {
|
||
console.error('注册页处理邀请参数失败:', e)
|
||
}
|
||
}, [router?.params])
|
||
|
||
const navigateAfterLogin = async () => {
|
||
if (!redirectUrl) {
|
||
await Taro.reLaunch({ url: '/pages/index/index' })
|
||
return
|
||
}
|
||
|
||
if (isTabBarUrl(redirectUrl)) {
|
||
// switchTab 不支持携带 query,这里按纯路径跳转
|
||
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
|
||
return
|
||
}
|
||
|
||
// 替换当前注册页,避免返回栈里再回到注册页
|
||
await Taro.redirectTo({ url: redirectUrl })
|
||
}
|
||
|
||
/** 手机号授权失败时弹窗引导走短信注册/登录(区分原因) */
|
||
const showPhoneAuthFailedModal = (errMsg?: string) => {
|
||
let content = '请尝试使用短信验证码注册/登录'
|
||
if (errMsg?.includes('user deny')) {
|
||
content = '您已取消微信手机号授权,可改用短信验证码注册/登录'
|
||
} else if (errMsg?.includes('privacy')) {
|
||
content = '微信未授权获取手机号,可改用短信验证码注册/登录'
|
||
} else if (errMsg?.includes('no permission')) {
|
||
content = '小程序暂未获得手机号授权,可改用短信验证码注册/登录'
|
||
}
|
||
Taro.showModal({
|
||
title: '未获取到手机号',
|
||
content,
|
||
confirmText: '短信注册/登录',
|
||
cancelText: '我知道了',
|
||
confirmColor: '#07c160',
|
||
success: (res) => {
|
||
if (res.confirm) goSmsLogin()
|
||
},
|
||
})
|
||
}
|
||
|
||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||
if (!isAgree) {
|
||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||
return
|
||
}
|
||
if (loading) return
|
||
|
||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||
showPhoneAuthFailedModal(errMsg)
|
||
return
|
||
}
|
||
|
||
try {
|
||
setLoading(true)
|
||
|
||
// 获取存储的邀请参数(推荐人ID)
|
||
const inviteParams = getStoredInviteParams()
|
||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||
|
||
// 获取小程序登录 code(用于后续绑定 openid)
|
||
const wxLoginCode = await getWeappLoginCode()
|
||
|
||
const res: any = await request.post(
|
||
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||
{
|
||
code: phoneCode,
|
||
encryptedData,
|
||
iv,
|
||
notVerifyPhone: true,
|
||
refereeId: refereeId,
|
||
sceneType: 'save_referee',
|
||
tenantId: TenantId,
|
||
},
|
||
{ showError: false }
|
||
)
|
||
|
||
if (res?.code === 1) {
|
||
Taro.showToast({ title: res.message || '登录失败', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const token = res?.data?.access_token
|
||
const user = res?.data?.user
|
||
if (!token || !user?.userId) {
|
||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
// 检查用户是否被禁用
|
||
if (isUserDisabled(user)) {
|
||
Taro.showModal({
|
||
title: '账号已被禁用',
|
||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||
showCancel: false,
|
||
confirmText: '我知道了',
|
||
confirmColor: '#ee0a24',
|
||
})
|
||
return
|
||
}
|
||
|
||
saveStorageByLoginUser(token, user)
|
||
|
||
// 注册/登录成功后,立即补齐 openid(JSAPI 支付必需)
|
||
try {
|
||
await ensureWxOpenIdSaved({ user, wxLoginCode })
|
||
} catch (e) {
|
||
console.error('注册页绑定 openid 失败:', e)
|
||
}
|
||
|
||
// 登录成功后尝试绑定推荐关系(如果有待处理 inviter,会自动处理并清理参数)
|
||
try {
|
||
await checkAndHandleInviteRelation()
|
||
} catch (e) {
|
||
console.error('注册页登录后处理邀请关系失败:', e)
|
||
}
|
||
|
||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||
setTimeout(() => {
|
||
navigateAfterLogin().catch((e) => console.error('登录后跳转失败:', e))
|
||
}, 800)
|
||
} catch (e: any) {
|
||
console.error('注册/登录失败:', e)
|
||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const goSmsLogin = () => {
|
||
const inviteParams = getStoredInviteParams()
|
||
const inviter = inviteParams?.inviter
|
||
const source = inviteParams?.source
|
||
const t = inviteParams?.t
|
||
|
||
const params: Record<string, any> = {}
|
||
if (redirectUrl) params.redirect = redirectUrl
|
||
// 兜底:把 inviter 带过去,避免“先点注册再进入”时丢失
|
||
if (inviter) params.inviter = inviter
|
||
if (source) params.source = source
|
||
if (t) params.t = t
|
||
|
||
const qs = Object.entries(params)
|
||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||
.join('&')
|
||
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<View className='flex flex-col justify-center px-5 pt-5'>
|
||
<View className='text-3xl text-center py-5 font-normal my-6'>注册/登录</View>
|
||
|
||
<View className='flex flex-col gap-3 bg-green-600 py-1' style={{ borderRadius: '100px' }}>
|
||
<Button
|
||
type='primary'
|
||
fill='solid'
|
||
color='#07c160'
|
||
block
|
||
loading={loading}
|
||
disabled={!isAgree || loading}
|
||
open-type='getPhoneNumber'
|
||
onGetPhoneNumber={handleGetPhoneNumber}
|
||
>
|
||
手机号一键注册/登录
|
||
</Button>
|
||
|
||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||
短信验证码注册/登录
|
||
</Button>
|
||
</View>
|
||
|
||
<View className='mt-6 flex text-sm items-center px-1'>
|
||
<Checkbox checked={isAgree} onChange={(val) => setIsAgree(val)} />
|
||
<Text className='text-gray-400' onClick={() => setIsAgree(!isAgree)}>
|
||
勾选表示您已阅读并同意
|
||
</Text>
|
||
<Text
|
||
onClick={() => Taro.navigateTo({ url: '/passport/agreement' })}
|
||
className='text-blue-600'
|
||
>
|
||
《服务协议及隐私政策》
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default Register
|