diff --git a/app/(tabs)/generate.tsx b/app/(tabs)/generate.tsx index 8a615e6..b134c78 100644 --- a/app/(tabs)/generate.tsx +++ b/app/(tabs)/generate.tsx @@ -200,7 +200,7 @@ export default function Generate() { + diff --git a/app/auth.tsx b/app/auth.tsx index 2775927..aad5edd 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,30 +1,33 @@ import { Ionicons } from '@expo/vector-icons' -import { Block, Text, Toast, VideoBox } from '@share/components' +import { Block, Text, Toast } from '@share/components' import { router } from 'expo-router' import React, { useCallback, useState } from 'react' import { TextInput } from 'react-native' import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' +import BannerSection from '@/components/BannerSection' import { useAuth } from '@/hooks/core/use-auth' import { setAuthToken } from '@/lib/auth' -const BACKGROUND_VIDEOS = [ - 'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4', - 'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4', - 'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4', -] - type AuthMode = 'login' | 'register' +const APP_NAME = '多米' + +// 判断是否为邮箱格式的辅助函数 +const isEmail = (input: string) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input) +} + export default function Auth() { const { signIn, signUp } = useAuth() const [mode, setMode] = useState('login') - const [bgVideo] = useState(() => BACKGROUND_VIDEOS[Math.floor(Math.random() * BACKGROUND_VIDEOS.length)]) const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [loading, setLoading] = useState(false) const handleLogin = useCallback(async () => { @@ -35,26 +38,50 @@ export default function Auth() { setLoading(true) try { - const result = await signIn.username( - { - username, - password, - }, - { - onSuccess: async (ctx) => { - const authToken = ctx.response.headers.get('set-auth-token') - if (authToken) { - setAuthToken(authToken) - } + let result + + if (isEmail(username)) { + // 如果用户名是邮箱格式,使用邮箱登录 + result = await signIn.email( + { + email: username, + password, }, - onError: (ctx) => { - console.error(`[LOGIN] login error`, ctx) + { + 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: result.error.message || '登录失败' }) + Toast.show({ title: '请输入正确的账号或密码' }) } else { Toast.show({ title: '登录成功!' }) router.replace('/(tabs)') @@ -115,10 +142,7 @@ export default function Auth() { return ( - - - - + @@ -126,7 +150,7 @@ export default function Auth() { - LOOMART + {APP_NAME} @@ -167,7 +191,7 @@ export default function Auth() { + setShowPassword(!showPassword)} className="p-1"> + + + {mode === 'login' && ( + + router.push('/forgot-password')} + > + 忘记密码 + + + )} + {mode === 'register' && ( 确认密码 @@ -263,8 +301,8 @@ export default function Auth() { > + setShowConfirmPassword(!showConfirmPassword)} className="p-1"> + + )} diff --git a/app/forgot-password.tsx b/app/forgot-password.tsx new file mode 100644 index 0000000..cc71750 --- /dev/null +++ b/app/forgot-password.tsx @@ -0,0 +1,284 @@ +// ...existing code... +import { Ionicons } from '@expo/vector-icons' +import { Block, Text, Toast } from '@share/components' +import { router } from 'expo-router' +import React, { useCallback, useState } from 'react' +import { TextInput } from 'react-native' +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' + +import BannerSection from '@/components/BannerSection' +import { emailOtp, forgetPassword } from '@/lib/auth' + +const APP_NAME = '多米' + +// 邮箱格式验证函数 +const isValidEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +export default function ForgotPassword() { + const [step, setStep] = useState<'send' | 'reset'>('send') + const [email, setEmail] = useState('') + const [code, setCode] = useState('') // 邮件中的验证码/token + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const [showNewPassword, setShowNewPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const handleSend = useCallback(async () => { + if (!email) { + Toast.show({ title: '请输入邮箱' }) + return + } + if (!isValidEmail(email)) { + Toast.show({ title: '请输入正确的邮箱格式' }) + return + } + setLoading(true) + try { + // better-auth的forgetPassword可能使用emailOtp方法 + const result = await forgetPassword.emailOtp({ email }) + if (result.error) { + Toast.show({ title: '邮箱未注册,发送失败' }) + } else { + Toast.show({ title: '重置验证码已发送到邮箱,请查看邮箱后继续' }) + setStep('reset') + } + } catch (err) { + const error = err as Error + Toast.show({ title: error?.message || '发送失败,请稍后重试' }) + } finally { + setLoading(false) + } + }, [email]) + + const handleReset = useCallback(async () => { + if (!code) { + Toast.show({ title: '请输入邮件中的重置令牌' }) + return + } + if (!newPassword || !confirmPassword) { + Toast.show({ title: '请输入新密码并确认' }) + return + } + if (newPassword !== confirmPassword) { + Toast.show({ title: '两次密码输入不一致' }) + return + } + if (newPassword.length < 6) { + Toast.show({ title: '密码长度至少6位' }) + return + } + + setLoading(true) + try { + const result = await emailOtp.resetPassword({ email, otp: code, password: newPassword }) + + if (result.error) { + Toast.show({ title: result.error.message || '重置失败,请稍后重试' }) + } else { + Toast.show({ title: '密码已重置,请使用新密码登录' }) + router.replace('/auth') + } + } catch (err) { + const error = err as Error + Toast.show({ title: error?.message || '重置失败,请稍后重试' }) + } finally { + setLoading(false) + } + }, [code, newPassword, confirmPassword]) + + return ( + + + + + + + + + + {APP_NAME} + + + + + + + {/* 发送邮箱步骤 */} + {step === 'send' && ( + + 邮箱 + + + + + + )} + + {/* 重置密码步骤 */} + {step === 'reset' && ( + + 邮箱 + + + + + + 重置令牌(邮件中获取) + + + + + + 新密码 + + + + setShowNewPassword(!showNewPassword)} className="p-1"> + + + + + 确认密码 + + + + setShowConfirmPassword(!showConfirmPassword)} className="p-1"> + + + + + )} + + + {loading ? ( + + ) : ( + + )} + + {loading ? '处理中...' : step === 'send' ? '发送重置链接' : '重置密码'} + + + + + { + if (step === 'send') router.replace('/auth') + else setStep('send') + }} + > + {step === 'send' ? '返回登录' : '返回发送重置邮件'} + + + + + + + + © 2025 LOOMART. All rights reserved. + + + + + + + ) +} diff --git a/hooks/core/use-loomart-api.ts b/hooks/core/use-loomart-api.ts index 69bce8f..46f1158 100644 --- a/hooks/core/use-loomart-api.ts +++ b/hooks/core/use-loomart-api.ts @@ -1,12 +1,13 @@ -import { ApiError } from "@/lib/types" -import { useState, useCallback } from "react" +import { useCallback, useState } from 'react' + +import { type ApiError } from '@/lib/types' export const useLoomartApi = ( apiFn: (params: TParams) => Promise<{ data?: TData; error?: ApiError }>, options?: { enabled?: boolean initialLoad?: boolean - } + }, ) => { const [data, setData] = useState() const [loading, setLoading] = useState(false) @@ -35,7 +36,7 @@ export const useLoomartApi = ( setLoading(false) } }, - [apiFn] + [apiFn], ) return { data, loading, error, execute, refetch: execute } diff --git a/hooks/core/use-route-guard.ts b/hooks/core/use-route-guard.ts index 3598830..f2b9918 100644 --- a/hooks/core/use-route-guard.ts +++ b/hooks/core/use-route-guard.ts @@ -10,6 +10,7 @@ const PUBLIC_ROUTES = [ 'auth', // 登录注册页 'pointList', // 积分列表页 'explore', + 'forget-password', ] function isPublicRoute(segments: string[]): boolean { diff --git a/hooks/core/use-user-balance.ts b/hooks/core/use-user-balance.ts index 0b0fa98..c8213dd 100644 --- a/hooks/core/use-user-balance.ts +++ b/hooks/core/use-user-balance.ts @@ -1,6 +1,7 @@ -import { subscription } from "@/lib/auth" -import { ApiError } from "@/lib/types" -import { useState } from "react" +import { useState } from 'react' + +import { subscription } from '@/lib/auth' +import { type ApiError } from '@/lib/types' export const useUserBalance = () => { const [loading, setLoading] = useState(false) @@ -17,7 +18,7 @@ export const useUserBalance = () => { return } - const meteredSubscriptions = data?.filter(sub => sub.type === 'metered') || [] + const meteredSubscriptions = data?.filter((sub) => sub.type === 'metered') || [] const creditBalance = meteredSubscriptions[0]?.creditBalance?.remainingTokenBalance || 0 setBalance(creditBalance) } catch (e) { diff --git a/lib/auth.ts b/lib/auth.ts index fee1566..91e79c5 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,21 +1,23 @@ import 'reflect-metadata' -import { createAuthClient } from 'better-auth/react' -import { - usernameClient, - phoneNumberClient, - emailOTPClient, - genericOAuthClient, - adminClient, - organizationClient, - jwtClient, -} from 'better-auth/client/plugins' + import { expoClient } from '@better-auth/expo/client' import { createSkerClientPlugin } from '@repo/sdk' -import { stripeClient } from './plugins/stripe-plugin' -import type { Subscription } from './plugins/stripe' -import type { ApiError } from './types' +import { + adminClient, + emailOTPClient, + genericOAuthClient, + jwtClient, + organizationClient, + phoneNumberClient, + usernameClient, +} from 'better-auth/client/plugins' +import { createAuthClient } from 'better-auth/react' + import { fetchWithLogger } from './fetch-logger' +import type { Subscription } from './plugins/stripe' +import { stripeClient } from './plugins/stripe-plugin' import { storage } from './storage' +import type { ApiError } from './types' export interface CreditBalance { tokenUsage: number @@ -106,6 +108,7 @@ export const authClient = createAuthClient({ ], }) -export const { signIn, signUp, signOut, useSession, $Infer, admin } = authClient +export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } = + authClient export const subscription: ISubscription = Reflect.get(authClient, 'subscription') diff --git a/lib/fetch-logger.ts b/lib/fetch-logger.ts index 948ced8..305945e 100644 --- a/lib/fetch-logger.ts +++ b/lib/fetch-logger.ts @@ -1,5 +1,3 @@ -import { router } from 'expo-router' - import { storage } from './storage' interface FetchLoggerOptions { @@ -43,7 +41,7 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => { if (response.status === 401) { console.warn('🔐 401 未授权,跳转到登录页') - router.replace('/auth') + // router.replace('/auth') } return response