Files
xinlong-shop-taro/src/passport/login.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

399 lines
13 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, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Image, Text, Button } from '@tarojs/components'
import { TenantId } from '@/config/app'
import { getWxOpenId } from '@/api/layout'
import { getUserInfo } 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 {
checkAndHandleInviteRelation,
hasPendingInvite,
parseInviteParams,
saveInviteParams,
trackInviteSource,
} from '@/utils/invite'
import './login.scss'
interface GetPhoneNumberDetail {
code?: string
encryptedData?: string
iv?: string
errMsg?: string
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail
}
/** 同步判断当前是否是微信小程序环境 */
function detectIsWeapp(): boolean {
try {
return Taro.getEnv() === Taro.ENV_TYPE.WEAPP
} catch {
return process.env.TARO_ENV === 'weapp'
}
}
const IS_WEAPP = detectIsWeapp()
/** 小程序登录获取 code */
async function getWeappLoginCode(): Promise<string | undefined> {
try {
const res = await new Promise<{ code?: string }>((resolve, reject) => {
Taro.login({ success: (r) => resolve(r), fail: reject })
})
return res?.code
} catch {
return undefined
}
}
/** 确保微信 openid 已保存到服务端 */
async function ensureWxOpenIdSaved() {
try {
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return
} catch {
if (process.env.TARO_ENV !== 'weapp') return
}
const code = await getWeappLoginCode()
if (!code) return
try {
await getWxOpenId({ code })
const freshUser = await getUserInfo()
if (freshUser) {
const token = Taro.getStorageSync('access_token')
saveStorageByLoginUser(token, freshUser)
}
} catch (e) {
console.error('登录后绑定 openid 失败:', e)
}
}
/** 获取用户信息(使用临时 token避免本地 storage 尚未写入) */
async function fetchUserInfo(token: string) {
try {
const res: any = await request.get(
`${SERVER_API_URL}/auth/user`,
{},
{
header: {
Authorization: `Bearer ${token}`,
},
showError: false,
}
)
if (res?.code === 0 && res?.data) {
return res.data
}
return null
} catch (e) {
console.error('获取用户信息失败:', e)
return null
}
}
const Login = () => {
const [isAgree, setIsAgree] = useState(false)
const [loading, setLoading] = useState(false)
const [showContent, setShowContent] = useState(false)
const router = Taro.getCurrentInstance().router
const isWeapp = IS_WEAPP
/** 页面加载动画 */
useEffect(() => {
setTimeout(() => setShowContent(true), 100)
}, [])
/** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */
useDidShow(() => {
if (!isWeapp) return
ensurePrivacyAuthorized().catch((e) => {
console.warn('登录页隐私协议预检失败:', e)
})
})
/** 解析 redirect 参数 */
const redirectUrl = (() => {
const raw = (router?.params as Record<string, string> | undefined)?.redirect
if (!raw) return ''
try {
const decoded = decodeURIComponent(raw)
return decoded.startsWith('/') ? decoded : `/${decoded}`
} catch {
return raw.startsWith('/') ? raw : `/${raw}`
}
})()
/** 处理邀请参数 */
useEffect(() => {
try {
const inviteParams = parseInviteParams({ query: router?.params })
if (inviteParams?.inviter) {
saveInviteParams(inviteParams)
trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter, 10))
}
} catch (e) {
console.error('登录页处理邀请参数失败:', e)
}
}, [router?.params])
/** 登录成功后跳转 */
const navigateAfterLogin = async () => {
if (!redirectUrl) {
await Taro.reLaunch({ url: '/pages/index/index' })
return
}
const tabBarUrls = [
'/pages/index/index',
'/pages/shop/index',
'/pages/user/user',
'/pages/shop/cart',
]
const pure = redirectUrl.split('?')[0]
if (tabBarUrls.includes(pure)) {
await Taro.switchTab({ url: pure })
return
}
await Taro.redirectTo({ url: redirectUrl })
}
/** 跳转到短信验证码登录(降级方案) */
const goSmsLogin = () => {
const params: Record<string, string> = {}
if (redirectUrl) params.redirect = redirectUrl
// 透传邀请参数,避免从登录页切到短信登录时丢失 inviter
const inviteParams = parseInviteParams({ query: router?.params })
if (inviteParams?.inviter) params.inviter = inviteParams.inviter
if (inviteParams?.source) params.source = inviteParams.source
if (inviteParams?.t) params.t = inviteParams.t
const qs = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
}
/** 手机号授权失败时弹窗引导走短信登录(区分原因) */
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, errMsg } = detail || {}
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
showPhoneAuthFailedModal(errMsg)
return
}
try {
setLoading(true)
const inviteParams = parseInviteParams({ query: router?.params })
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
const res: any = await request.post(
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
{
code: phoneCode,
notVerifyPhone: true,
refereeId,
sceneType: 'save_referee',
tenantId: Number(TenantId),
},
{ showError: false }
)
if (res?.code === 0 && res?.data?.access_token) {
const token = res.data.access_token
let user = res.data.user
// 获取最新的用户信息
const freshUserInfo = await fetchUserInfo(token)
if (freshUserInfo) {
user = freshUserInfo
}
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
// 绑定 openid + 处理邀请关系
await ensureWxOpenIdSaved()
if (hasPendingInvite()) {
try { await checkAndHandleInviteRelation() } catch (e) { console.error(e) }
}
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => navigateAfterLogin(), 800)
} else {
Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
}
} catch (e: any) {
console.error('微信登录失败:', e)
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
} finally {
setLoading(false)
}
}
return (
<View className={`page-login ${showContent ? 'page-login--show' : ''}`}>
{/* 渐变背景 */}
<View className='login-bg'>
<View className='login-bg__gradient' />
<View className='login-bg__circle login-bg__circle--1' />
<View className='login-bg__circle login-bg__circle--2' />
<View className='login-bg__circle login-bg__circle--3' />
</View>
{/* 主要内容区域 */}
<View className='login-content'>
{/* Logo 和标题 */}
<View className='login-header'>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
{/* 登录按钮区域 */}
<View className='login-body'>
{isWeapp && (
<View className='login-methods'>
<Button
className='login-btn'
style={{
background: 'linear-gradient(135deg, #07c160, #06ad56)',
border: 'none',
borderRadius: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontWeight: '600',
textAlign: 'center',
opacity: (!isAgree || loading) ? 0.5 : 1,
margin: 0,
padding: 0,
}}
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhoneNumber}
disabled={!isAgree || loading}
loading={loading}
>
</Button>
{/*<View className='login-methods__tip text-gray-200'>*/}
{/* <Text>点击上方按钮,快速登录</Text>*/}
{/*</View>*/}
<View className='login-methods__alt'>
<Text
className='login-methods__alt-link'
onClick={() => goSmsLogin()}
>
使
</Text>
</View>
<View className='login-methods__divider'>
<View className='divider-line' />
<Text className='divider-text'></Text>
<View className='divider-line' />
</View>
<View className='login-features'>
<View className='feature-item'>
<Text className='feature-icon'>🔒</Text>
<Text className='feature-text'></Text>
</View>
<View className='feature-item'>
<Text className='feature-icon'></Text>
<Text className='feature-text'></Text>
</View>
<View className='feature-item'>
<Text className='feature-icon'>🎁</Text>
<Text className='feature-text'></Text>
</View>
</View>
</View>
)}
{/* 非微信小程序环境提示 */}
{!isWeapp && (
<View className='login-non-weapp'>
<View className='non-weapp-icon'>💻</View>
<Text className='non-weapp-title'></Text>
<Text className='non-weapp-desc'>使</Text>
</View>
)}
</View>
{/* 协议勾选 - 自定义实现 */}
<View className='login-footer'>
<View className='login-agreement' onClick={() => setIsAgree(!isAgree)}>
<View className={`login-agreement__checkbox ${isAgree ? 'login-agreement__checkbox--checked' : ''}`}>
{isAgree && <Text className='login-agreement__check'></Text>}
</View>
<Text className='login-agreement__text'>
{'我已阅读并同意'}
<Text className='link' onClick={(e) => { e.stopPropagation(); Taro.navigateTo({ url: '/passport/agreement?type=terms' }) }}>
{'《用户协议》'}
</Text>
{'和'}
<Text className='link' onClick={(e) => { e.stopPropagation(); Taro.navigateTo({ url: '/passport/agreement?type=privacy' }) }}>
{'《隐私政策》'}
</Text>
</Text>
</View>
</View>
</View>
</View>
)
}
export default Login