feat(auth): 添加统一认证工具和优化登录流程

- 新增 auth 工具模块,包含 isLoggedIn、goToRegister、ensureLoggedIn 方法
- 将硬编码的服务器URL更新为 glt-server 域名
- 重构多个页面的登录检查逻辑,使用统一的认证工具
- 在用户注册/登录流程中集成邀请关系处理
- 更新注册页面配置和实现,支持跳转参数传递
- 优化分销商二维码页面的加载状态和错误处理
- 在水票使用页面添加无票时的购买引导
- 统一文件上传和API请求的服务器地址
- 添加加密库类型定义文件
This commit is contained in:
2026-02-13 21:30:58 +08:00
parent 52ef8d4199
commit e22cfe4646
23 changed files with 712 additions and 154 deletions

View File

@@ -1,28 +1,47 @@
import {useEffect, useState} from "react";
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import {View, RichText} from '@tarojs/components'
import { Loading } from '@nutui/nutui-react-taro'
import { RichText, View } from '@tarojs/components'
import { getByCode } from '@/api/cms/cmsArticle'
import { wxParse } from '@/utils/common'
const Agreement = () => {
const [loading, setLoading] = useState(true)
const [content, setContent] = useState<string>('')
const [content, setContent] = useState<any>('')
const reload = () => {
Taro.hideTabBar()
setContent('<p>' +
'<span style="font-size: 14px;">欢迎使用</span>' +
'<span style="font-size: 14px;">&nbsp;</span>' +
'<span style="font-size: 14px;"><strong><span style="color: rgb(255, 0, 0);">【WebSoft】</span></strong></span>' +
'<span style="font-size: 14px;">服务协议&nbsp;</span>' +
'</p>')
const reload = async () => {
try {
Taro.hideTabBar()
} catch (_) {
// ignore (e.g. H5 / unsupported env)
}
try {
const article = await getByCode('xieyi')
setContent(article?.content ? wxParse(article.content) : '<p>暂无协议内容</p>')
} catch (e) {
// Keep UI usable even if CMS/API fails.
// eslint-disable-next-line no-console
console.error('load agreement failed', e)
setContent('<p>协议内容加载失败</p>')
Taro.showToast({ title: '协议加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
useEffect(() => {
reload()
}, [])
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<View className={'content text-gray-700 text-sm p-4'}>
<RichText nodes={content}/>
<RichText nodes={content} />
</View>
</>
)

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '注册/登录',
navigationBarTextStyle: 'black'
})

295
src/passport/register.tsx Normal file
View File

@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from 'react'
import Taro from '@tarojs/taro'
import { Button, Radio } from '@nutui/nutui-react-taro'
import { TenantId } from '@/config/app'
import { getUserInfo, getWxOpenId } from '@/api/layout'
import { saveStorageByLoginUser } from '@/utils/server'
import {
getStoredInviteParams,
parseInviteParams,
saveInviteParams,
trackInviteSource,
checkAndHandleInviteRelation,
} from '@/utils/invite'
interface GetPhoneNumberDetail {
code?: string
encryptedData?: string
iv?: string
errMsg?: string
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail
}
interface LoginResponse {
data: {
code?: number
message?: string
data?: {
access_token: string
user: any
}
}
}
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/cart/cart' ||
pure === '/pages/user/user' ||
pure === '/pages/category/index'
)
}
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
useEffect(() => {
// 注册/登录页不需要展示 tabBar
Taro.hideTabBar()
}, [])
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 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'))) {
Taro.showToast({ title: '未授权手机号', icon: 'none' })
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 = (await Taro.request({
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code: phoneCode,
encryptedData,
iv,
notVerifyPhone: true,
refereeId: refereeId,
sceneType: 'save_referee',
tenantId: TenantId,
},
header: {
'content-type': 'application/json',
TenantId,
},
})) as unknown as LoginResponse
if ((res as any)?.data?.code === 1) {
Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
return
}
const token = res?.data?.data?.access_token
const user = res?.data?.data?.user
if (!token || !user?.userId) {
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
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 (
<>
<div className={'flex flex-col justify-center px-5 pt-5'}>
<div className={'text-3xl text-center py-5 font-normal my-6'}>/</div>
<div 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>
{!isWeapp && (
<Button type="default" block disabled={!isAgree || loading} onClick={goSmsLogin}>
/
</Button>
)}
</div>
<div className={'mt-6 flex text-sm items-center px-1'}>
<Radio style={{ color: '#333333' }} checked={isAgree} onClick={() => setIsAgree(!isAgree)} />
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}>
</span>
<a
onClick={() => Taro.navigateTo({ url: '/passport/agreement' })}
className={'text-blue-600'}
>
</a>
</div>
</div>
</>
)
}
export default Register

View File

@@ -3,7 +3,7 @@ import Taro from '@tarojs/taro'
import {Input, Button} from '@nutui/nutui-react-taro'
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
import {LoginParam} from "@/api/passport/login/model";
import {checkAndHandleInviteRelation, hasPendingInvite} from "@/utils/invite";
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
const SmsLogin = () => {
const [loading, setLoading] = useState<boolean>(false)
@@ -14,6 +14,46 @@ const SmsLogin = () => {
code: ''
})
const router = Taro.getCurrentInstance().router
const redirectParam = (router?.params as any)?.redirect as string | undefined
const safeDecodeMaybeEncoded = (input?: string) => {
if (!input) return ''
try {
return decodeURIComponent(input)
} catch (_e) {
return input
}
}
const redirectUrl = (() => {
const decoded = safeDecodeMaybeEncoded(redirectParam)
if (!decoded) return ''
return decoded.startsWith('/') ? decoded : `/${decoded}`
})()
const isTabBarUrl = (url: string) => {
const pure = url.split('?')[0]
return (
pure === '/pages/index/index' ||
pure === '/pages/cart/cart' ||
pure === '/pages/user/user' ||
pure === '/pages/category/index'
)
}
const navigateAfterLogin = async () => {
if (!redirectUrl) {
await Taro.reLaunch({ url: '/pages/index/index' })
return
}
if (isTabBarUrl(redirectUrl)) {
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
return
}
await Taro.redirectTo({ url: redirectUrl })
}
const reload = () => {
Taro.hideTabBar()
}
@@ -22,6 +62,19 @@ const SmsLogin = () => {
reload()
}, [])
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
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])
// 倒计时效果
useEffect(() => {
let timer: NodeJS.Timeout
@@ -148,8 +201,9 @@ const SmsLogin = () => {
// 延迟跳转到首页
setTimeout(() => {
Taro.reLaunch({
url: '/pages/index/index'
navigateAfterLogin().catch((e) => {
console.error('短信登录后跳转失败:', e)
Taro.reLaunch({ url: '/pages/index/index' })
})
}, 1500)