Files
xinlong-shop-taro/src/passport/register.tsx
赵忠林 ca78e73f51 feat(pay): 新增小程序支付页面及相关隐私协议处理
- 新增支付页面组件,实现JSAPI支付流程和多状态页面展示
- 配置支付页面导航条样式和标题
- app.config.ts中添加支付页面路径
- app.tsx中新增微信隐私协议授权弹窗,拦截敏感API授权流程
- api/system/file中导出ensurePrivacyAuthorized用于隐私授权预检
- 登录页和注册页新增useDidShow钩子调用ensurePrivacyAuthorized预检隐私协议
- 登录和注册接口请求迁移至统一request封装,修正响应解构
- 支付流程中新增获取openid和自动登录逻辑,支持未注册用户跳转登录
- 支付成功后通知后端确认并自动跳转到已购产品页面
- 优化支付状态判断逻辑,正确识别支付和订单状态
- 添加错误处理和用户提示,提升支付体验安全性与稳定性
2026-07-16 00:26:42 +08:00

320 lines
9.9 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 { 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)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
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