expo-duooomi-app/app/auth.tsx

495 lines
16 KiB
TypeScript

import { Ionicons } from '@expo/vector-icons'
import { Block, Input, Text, Toast } from '@share/components'
import { router } from 'expo-router'
import * as Updates from 'expo-updates'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { TextInput } from 'react-native'
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'
import { APP_VERSION, VERSION } from '@/app.config'
import BannerSection from '@/components/BannerSection'
import { phoneNumber, setAuthToken, signIn } from '@/lib/auth'
import { isValidPhone } from '@/utils'
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 [currentBranch, setCurrentBranch] = useState<string>('unknown')
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { availableUpdate } = Updates.useUpdates()
useEffect(() => {
if (!Updates.isEnabled) {
setCurrentBranch('development')
return
}
const manifest = Updates.manifest
console.log('manifest--------', JSON.stringify(manifest?.extra))
console.log('manifest-------updateId-', JSON.stringify(Updates.updateId))
const branchName = manifest?.metadata?.branchName
console.log('当前运行的分支:', branchName)
console.log('availableUpdate-------', JSON.stringify(availableUpdate))
if (availableUpdate) {
// 获取即将下载/待安装更新的分支名
const targetBranch = availableUpdate.manifest?.metadata?.branchName
console.log('检测到新分支更新:', targetBranch)
}
if (manifest?.extra?.eas?.branch) {
setCurrentBranch(manifest.extra.eas.branch)
} else if (Updates.channel) {
setCurrentBranch(Updates.channel)
} else {
setCurrentBranch('production')
}
}, [])
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
}
setCountdown(60) // 开始倒计时
Toast.show({ title: '验证码已发送,请查收短信' })
// 倒计时逻辑
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 {
const result = await 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)
}
},
},
)
if (result.error) {
Toast.show({ title: result.error.message || '验证失败' })
} else {
Toast.show({ title: '登录成功!' })
router.replace('/(tabs)')
}
} 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-2 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={() => router.push('/service')}>
</Text>
<Text className="font-700 text-[12px] text-black"></Text>
<Text className="font-700 text-[12px] text-black underline" onClick={() => router.push('/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>
)
const renderLoginEmail = () => {
if (loginType !== 'email') return null
return (
<>
<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('登录')}
</>
)
}
const renderLoginPhone = () => {
if (loginType !== 'phone') return null
return (
<>
<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 }} />
<Input
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 }} />
<Input
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('登录/注册')}
</>
)
}
return (
<Block className="relative h-dvh flex-1 bg-black">
<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]">
{renderLoginPhone()}
{renderLoginEmail()}
{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">
: {VERSION} - {APP_VERSION} - {currentBranch}
</Text>
</Block>
</Block>
</Block>
<Block className="h-[200px]" />
</KeyboardAwareScrollView>
</Block>
)
}