Files
xinlong-shop-taro/src/passport/sms-login.tsx
赵忠林 b47ea6bd90 feat(passport): 重构短信登录页并提供手机号授权降级入口
- 修复购物车数据库缺失 spec_info 字段问题,新增迁移脚本
- 登录页处理手机号授权失败,按错误类型弹窗引导用户改用短信登录
- 登录页新增长期显示的「使用短信验证码登录」入口,提升用户降级体验
- 注册页同步改造,去除微信小程序环境下短信按钮隐藏问题
- 短信登录页整体 UI 重构,弃用 NutUI 组件,采用原生组件实现
- 短信登录页新增渐变背景、浮动光圈、圆形 logo、卡片输入区设计
- 实现手机号输入、验证码发送、倒计时及登录校验逻辑全覆盖
- 短信登录页新增返回微信快捷登录链接,避免用户被困登录流程
- 新增配套样式文件,实现多重动效及分阶段入场动画
- 保证类型检查和编译无误,增加设计稿 HTML 预览文件
2026-07-15 18:55:12 +08:00

328 lines
9.1 KiB
TypeScript

import {useEffect, useState} from "react";
import Taro from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
import {LoginParam} from "@/api/passport/login/model";
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
import './sms-login.scss'
const SmsLogin = () => {
const [loading, setLoading] = useState<boolean>(false)
const [sendingCode, setSendingCode] = useState<boolean>(false)
const [countdown, setCountdown] = useState<number>(0)
const [formData, setFormData] = useState<LoginParam>({
phone: '',
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 })
}
useEffect(() => {
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
}, [])
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
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: ReturnType<typeof setTimeout> | undefined
if (countdown > 0) {
timer = setTimeout(() => {
setCountdown(countdown - 1)
}, 1000)
}
return () => {
if (timer) clearTimeout(timer)
}
}, [countdown])
// 验证手机号格式
const validatePhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// 发送短信验证码
const handleSendCode = async () => {
if (!formData.phone) {
Taro.showToast({
title: '请输入手机号码',
icon: 'none'
})
return
}
if (!validatePhone(formData.phone)) {
Taro.showToast({
title: '请输入正确的手机号码',
icon: 'none'
})
return
}
if (sendingCode || countdown > 0) {
return
}
try {
setSendingCode(true)
await sendSmsCaptcha({ phone: formData.phone })
Taro.showToast({
title: '验证码已发送',
icon: 'success'
})
// 开始60秒倒计时
setCountdown(60)
} catch (error: any) {
Taro.showToast({
title: error.message || '发送失败',
icon: 'error'
})
} finally {
setSendingCode(false)
}
}
// 处理登录
const handleLogin = async () => {
// 防止重复提交
if (loading) {
return
}
// 表单验证
if (!formData.phone) {
Taro.showToast({
title: '请输入手机号码',
icon: 'none'
})
return
}
if (!validatePhone(formData.phone)) {
Taro.showToast({
title: '请输入正确的手机号码',
icon: 'none'
})
return
}
if (!formData.code) {
Taro.showToast({
title: '请输入验证码',
icon: 'none'
})
return
}
if (formData.code.length !== 6) {
Taro.showToast({
title: '请输入6位验证码',
icon: 'none'
})
return
}
try {
setLoading(true)
await loginBySms({
phone: formData.phone,
code: formData.code
})
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
try {
await checkAndHandleInviteRelation()
} catch (e) {
console.error('短信登录后处理邀请关系失败:', e)
}
}
Taro.showToast({
title: '登录成功',
icon: 'success'
})
// 延迟跳转到首页
setTimeout(() => {
navigateAfterLogin().catch((e) => {
console.error('短信登录后跳转失败:', e)
Taro.reLaunch({ url: '/pages/index/index' })
})
}, 1500)
} catch (error: any) {
Taro.showToast({
title: error.message || '登录失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
const phoneValid = validatePhone(formData.phone)
const codeValid = formData.code.length === 6
const canSubmit = phoneValid && codeValid && !loading
const sendDisabled = !phoneValid || sendingCode || countdown > 0
const sendBtnText = countdown > 0
? `${countdown}s 后重试`
: sendingCode
? '发送中'
: '获取验证码'
return (
<View className='page-sms-login'>
{/* 渐变背景 */}
<View className='sms-login-bg'>
<View className='sms-login-bg__gradient' />
<View className='sms-login-bg__circle sms-login-bg__circle--1' />
<View className='sms-login-bg__circle sms-login-bg__circle--2' />
</View>
<View className='sms-login-content'>
{/* 头部 Logo 和标题 */}
<View className='sms-login-header'>
<View className='sms-login-logo'>
<Text className='sms-login-logo__icon'>📱</Text>
</View>
<Text className='sms-login-title'></Text>
<Text className='sms-login-subtitle'></Text>
</View>
{/* 表单卡片 */}
<View className='sms-login-card'>
{/* 手机号 */}
<View className='sms-login-field'>
<Text className='sms-login-field__icon'>📞</Text>
<Input
className='sms-login-field__input'
type='number'
placeholder='请输入手机号码'
placeholderClass='sms-login-field__placeholder'
maxLength={11}
value={formData.phone}
onInput={(e: any) => setFormData({ ...formData, phone: e.detail.value })}
/>
</View>
<View className='sms-login-divider' />
{/* 验证码 */}
<View className='sms-login-field sms-login-field--code'>
<View className='sms-login-field__icon-wrap'>
<Text className='sms-login-field__icon'>🔐</Text>
<Input
className='sms-login-field__input'
type='number'
placeholder='请输入6位验证码'
placeholderClass='sms-login-field__placeholder'
maxLength={6}
value={formData.code}
onInput={(e: any) => setFormData({ ...formData, code: e.detail.value })}
/>
</View>
<View
className={`sms-login-send ${sendDisabled ? 'sms-login-send--disabled' : ''}`}
onClick={handleSendCode}
>
{sendBtnText}
</View>
</View>
</View>
{/* 登录按钮 */}
<View
className={`sms-login-submit ${canSubmit ? '' : 'sms-login-submit--disabled'}`}
hoverClass={canSubmit ? 'sms-login-submit--hover' : 'none'}
onClick={canSubmit ? handleLogin : undefined}
>
{loading ? '登录中...' : '登录 / 注册'}
</View>
{/* 返回微信登录 */}
<View
className='sms-login-back'
onClick={() => Taro.navigateBack({ delta: 1 })}
>
<Text className='sms-login-back__icon'></Text>
<Text className='sms-login-back__text'></Text>
</View>
{/* 协议 */}
<View className='sms-login-tips'>
<Text className='sms-login-tips__text'></Text>
<Text
className='sms-login-tips__link'
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=terms' })}
>
</Text>
<Text className='sms-login-tips__text'></Text>
<Text
className='sms-login-tips__link'
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=privacy' })}
>
</Text>
</View>
</View>
</View>
)
}
export default SmsLogin