- 修复购物车数据库缺失 spec_info 字段问题,新增迁移脚本 - 登录页处理手机号授权失败,按错误类型弹窗引导用户改用短信登录 - 登录页新增长期显示的「使用短信验证码登录」入口,提升用户降级体验 - 注册页同步改造,去除微信小程序环境下短信按钮隐藏问题 - 短信登录页整体 UI 重构,弃用 NutUI 组件,采用原生组件实现 - 短信登录页新增渐变背景、浮动光圈、圆形 logo、卡片输入区设计 - 实现手机号输入、验证码发送、倒计时及登录校验逻辑全覆盖 - 短信登录页新增返回微信快捷登录链接,避免用户被困登录流程 - 新增配套样式文件,实现多重动效及分阶段入场动画 - 保证类型检查和编译无误,增加设计稿 HTML 预览文件
328 lines
9.1 KiB
TypeScript
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
|