expo-duooomi-app/app/auth.tsx

464 lines
17 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 { Ionicons } from '@expo/vector-icons'
import { Block, Text, Toast } from '@share/components'
import { router } from 'expo-router'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { TextInput } from 'react-native'
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'
import { APP_VERSION } from '@/app.config'
import BannerSection from '@/components/BannerSection'
import { getSession, phoneNumber, setAuthToken, signIn } from '@/lib/auth'
import { isValidEmail, isValidPhone } from '@/utils'
import { openUrl } from '@/utils/webview-helper'
const APP_NAME = '多米'
type LoginType = 'phone' | 'email'
// 判断是否为邮箱格式的辅助函数
const isEmail = (input: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)
}
export default function Auth() {
const [loginType, setLoginType] = useState<LoginType>('phone')
const [phone, setPhone] = useState('')
const [code, setCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const [agreed, setAgreed] = useState(false)
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const canSendCode = useMemo(() => {
return isValidPhone(phone) && countdown === 0
}, [phone, countdown])
// 清理倒计时定时器
useEffect(() => {
return () => {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current)
}
}
}, [])
// 获取验证码
const handleSendCode = useCallback(async () => {
if (!isValidPhone(phone)) {
Toast.show({ title: '请输入有效的手机号' })
return
}
if (countdown > 0) {
return
}
Toast.showLoading({ title: '正在获取验证码...' })
setLoading(true)
try {
const result = await phoneNumber.sendOtp({
phoneNumber: phone,
})
if (result.error) {
// 处理不同类型的错误
const errorMessage = result.error.message || '验证码发送失败'
// 如果是 403 错误,可能是请求过于频繁
if (result.error.status === 403) {
Toast.show({ title: '请求过于频繁,请稍后再试' })
} else {
Toast.show({ title: errorMessage })
}
return
}
Toast.show({ title: '验证码已发送,请查收短信' })
setCountdown(60) // 开始倒计时
// 倒计时逻辑
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current)
}
countdownTimerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current)
countdownTimerRef.current = null
}
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
console.error('获取验证码失败:', error)
Toast.show({ title: error.message || '验证码发送失败,请稍后重试' })
} finally {
Toast.hideLoading()
setLoading(false)
}
}, [phone, countdown])
// 手机号验证码登录/注册(无需密码)
const handlePhoneLogin = useCallback(async () => {
if (!phone || !code) {
Toast.show({ title: '请填写手机号和验证码' })
return
}
if (!isValidPhone(phone)) {
Toast.show({ title: '请输入有效的手机号' })
return
}
if (code.length !== 6) {
Toast.show({ title: '请输入6位验证码' })
return
}
if (!agreed) {
Toast.show({ title: '请先阅读并同意服务条款和隐私协议' })
return
}
setLoading(true)
Toast.showLoading({ title: '正在验证...' })
try {
// 验证验证码,如果后端配置了 signUpOnVerification新用户会自动注册并创建 session
// disableSession: false 表示验证成功后创建 session自动登录
await new Promise<void>((resolve, reject) => {
phoneNumber.verify(
{ phoneNumber: phone, code: code, disableSession: false },
{
onSuccess: async (ctx: any) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
setAuthToken(authToken)
}
await getSession()
Toast.show({ title: '登录成功!' })
router.replace('/(tabs)')
},
onError: (ctx: any) => {
reject(new Error(ctx.error.message))
},
},
)
})
} catch (error: any) {
console.error('登录/注册失败:', error)
Toast.show({ title: error.message || '操作失败,请稍后重试' })
} finally {
Toast.hideLoading()
setLoading(false)
}
}, [phone, code, agreed])
// 邮箱登录
const handleEmailLogin = useCallback(async () => {
if (!username || !password) {
Toast.show({ title: '请填写账号和密码' })
return
}
if (!agreed) {
Toast.show({ title: '请先阅读并同意服务条款和隐私协议' })
return
}
setLoading(true)
Toast.showLoading({ title: '正在登录...' })
try {
let result
if (isEmail(username)) {
// 如果用户名是邮箱格式,使用邮箱登录
result = await signIn.email(
{
email: username,
password,
},
{
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
setAuthToken(authToken)
}
},
onError: (ctx) => {
console.error(`[LOGIN] email login error`, ctx)
},
},
)
} else {
// 如果用户名不是邮箱格式,使用用户名登录
result = await signIn.username(
{
username,
password,
},
{
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
setAuthToken(authToken)
}
},
onError: (ctx) => {
console.error(`[LOGIN] username login error`, ctx)
},
},
)
}
if (result.error) {
Toast.show({ title: '请输入正确的账号或密码' })
} else {
Toast.show({ title: '登录成功!' })
router.replace('/(tabs)')
}
} catch (error: any) {
Toast.show({ title: error.message || '登录失败' })
} finally {
Toast.hideLoading()
setLoading(false)
}
}, [username, password, agreed])
const handleLogin = loginType === 'phone' ? handlePhoneLogin : handleEmailLogin
// 服务条款同意组件
const renderAgreementCheckbox = () => (
<Block className="flex-row items-center gap-[8px]">
<Block
onClick={() => setAgreed(!agreed)}
className={`size-[20px] items-center justify-center border-[2px] border-black ${agreed ? 'bg-black' : 'bg-white'}`}
>
{agreed && <Ionicons color="#FFE500" name="checkmark" size={14} />}
</Block>
<Block className="flex-row flex-wrap items-center">
<Text className="font-700 text-[12px] text-black"></Text>
<Text
className="font-700 text-[12px] text-black underline"
onClick={() => openUrl('https://mixvideo.bowong.cc/terms', '服务条款')}
>
</Text>
<Text className="font-700 text-[12px] text-black"></Text>
<Text
className="font-700 text-[12px] text-black underline"
onClick={() => openUrl('https://mixvideo.bowong.cc/privacy', '隐私协议')}
>
</Text>
</Block>
</Block>
)
// 登录按钮组件
const renderLoginButton = (buttonText: string = '登录') => (
<Block
className={`font-900 mt-[8px] flex-row items-center justify-center gap-[8px] border-[3px] border-black py-[14px] shadow-hard-black ${loading ? 'bg-gray-300' : 'bg-accent'}`}
onClick={handleLogin}
>
{loading ? (
<Ionicons color="black" name="hourglass-outline" size={20} />
) : (
<Ionicons color="black" name="flash" size={20} />
)}
<Text className="font-900 text-[16px] text-black">{loading ? '处理中...' : buttonText}</Text>
</Block>
)
// 登录方式切换组件
const renderLoginTypeSwitch = () => (
<Block className="mt-[8px] flex-row items-center justify-center gap-[20px]">
{(['phone', 'email'] as const).map((type) => {
const isActive = type === loginType
return (
<Block
key={type}
style={{ transform: [{ skewX: '-6deg' }] }}
onClick={() => setLoginType(type)}
>
<Ionicons
color={isActive ? 'black' : '#9CA3AF'}
name={type === 'phone' ? 'phone-portrait' : 'mail'}
size={20}
style={{ transform: [{ skewX: '6deg' }] }}
/>
</Block>
)
})}
</Block>
)
return (
<Block className="relative flex-1 bg-black h-[100dvh]">
<BannerSection />
<KeyboardAwareScrollView bottomOffset={100}>
<Block className="flex-1 items-center justify-center px-[24px] py-[40px]">
<Block className="relative w-full max-w-screen-xs">
<Block className="relative mb-[32px] items-center">
<Block className="size-[80px] items-center justify-center rounded-full border-4 border-black bg-accent shadow-deep-black">
<Ionicons color="black" name="flash" size={40} />
</Block>
<Text className="font-900 mt-[16px] text-[32px] text-white">{APP_NAME}</Text>
<Block className="mt-[8px] h-[4px] w-[120px] bg-accent" />
</Block>
<Block
className="relative border-4 border-black bg-white p-[24px] shadow-deep-black"
style={{ transform: [{ skewX: '-3deg' }] }}
>
<Block style={{ transform: [{ skewX: '3deg' }] }}>
<Block className="mt-[4px] gap-[16px]">
{loginType === 'phone' ? (
<>
<Block>
<Text className="font-900 mb-[8px] text-[12px] text-black"></Text>
<Block
className="flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-medium-black"
style={{ height: 48 }}
>
<Ionicons color="black" name="phone-portrait-outline" size={20} style={{ marginRight: 8 }} />
<TextInput
autoCapitalize="none"
keyboardType="phone-pad"
placeholder="请输入手机号"
placeholderTextColor="#9CA3AF"
value={phone}
maxLength={11}
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
onChangeText={setPhone}
/>
<Block
onClick={countdown > 0 ? undefined : handleSendCode}
className={`border-2 border-black px-[6px] py-[4px] ${canSendCode && countdown === 0 ? 'bg-black' : 'bg-gray-200'}`}
>
<Text className={`text-[10px] font-[900] ${canSendCode && countdown === 0 ? 'text-accent' : 'text-gray-500'}`}>
{countdown > 0 ? `${countdown}` : '获取验证码'}
</Text>
</Block>
</Block>
</Block>
<Block>
<Text className="font-900 mb-[8px] text-[12px] text-black"></Text>
<Block
className="flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-medium-black"
style={{ height: 48 }}
>
<Ionicons color="black" name="key-outline" size={20} style={{ marginRight: 8 }} />
<TextInput
autoCapitalize="none"
keyboardType="number-pad"
placeholder="请输入验证码"
placeholderTextColor="#9CA3AF"
value={code}
maxLength={6}
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
onChangeText={setCode}
/>
</Block>
</Block>
{renderAgreementCheckbox()}
{renderLoginButton('登录/注册')}
</>
) : (
<>
<Block>
<Text className="font-900 mb-[8px] text-[12px] text-black"></Text>
<Block
className="flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-medium-black"
style={{ height: 48 }}
>
<Ionicons color="black" name="person-outline" size={20} style={{ marginRight: 8 }} />
<TextInput
autoCapitalize="none"
placeholder="用户名/邮箱"
placeholderTextColor="#9CA3AF"
value={username}
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
onChangeText={setUsername}
/>
</Block>
</Block>
<Block>
<Text className="font-900 mb-[8px] text-[12px] text-black"></Text>
<Block
className="flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-medium-black"
style={{ height: 48 }}
>
<Ionicons color="black" name="lock-closed-outline" size={20} style={{ marginRight: 8 }} />
<TextInput
secureTextEntry={!showPassword}
placeholder="请输入密码"
placeholderTextColor="#9CA3AF"
value={password}
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
onChangeText={setPassword}
/>
<Block onClick={() => setShowPassword(!showPassword)} className="p-1">
<Ionicons color="black" name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} />
</Block>
</Block>
</Block>
<Block className="flex-row items-center justify-between">
<Block className="flex-1">
{renderAgreementCheckbox()}
</Block>
<Text className="font-700 text-[12px] text-gray-500" onClick={() => router.push('/forgotPassword')}>
</Text>
</Block>
{renderLoginButton('登录')}
</>
)}
{renderLoginTypeSwitch()}
</Block>
</Block>
</Block>
<Block className="mt-[24px] items-center">
<Text className="font-700 text-[12px] text-gray-400">© 2025 LOOMART. All rights reserved.</Text>
<Text className="font-700 text-[12px] text-gray-400">: {APP_VERSION}</Text>
</Block>
</Block>
</Block>
<Block className="h-[200px]" />
</KeyboardAwareScrollView>
</Block>
)
}