Compare commits
13 Commits
a2ba14d312
...
37a4a2f807
| Author | SHA1 | Date |
|---|---|---|
|
|
37a4a2f807 | |
|
|
2f290eeedd | |
|
|
a66bcca38b | |
|
|
40571d2337 | |
|
|
7cf896215c | |
|
|
3eeff10338 | |
|
|
3f1f10be49 | |
|
|
99e2c5fafc | |
|
|
2d0a21205f | |
|
|
ee119d472f | |
|
|
c2c097efb3 | |
|
|
24fd3a8847 | |
|
|
1a6df3c806 |
|
|
@ -8,7 +8,7 @@ node_modules/
|
|||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
tmpclaude-*
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Image } from 'expo-image'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StatusBar as RNStatusBar,
|
||||
Animated,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Image } from 'expo-image'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PointsIcon, SearchIcon, DownArrowIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { AuthForm } from '@/components/blocks/AuthForm'
|
||||
import { DownArrowIcon, PointsIcon, SearchIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { useActivates } from '@/hooks/use-activates'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
|
@ -279,10 +278,6 @@ export default function HomeScreen() {
|
|||
{renderTabs()}
|
||||
</View>
|
||||
|
||||
<View className='py-6'>
|
||||
<AuthForm mode='register' />
|
||||
</View>
|
||||
|
||||
{/* 内容网格 */}
|
||||
<View
|
||||
style={styles.gridContainer}
|
||||
|
|
|
|||
104
app/_layout.tsx
104
app/_layout.tsx
|
|
@ -1,42 +1,110 @@
|
|||
import '../global.css'
|
||||
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
||||
import { Stack } from 'expo-router'
|
||||
import { Stack, usePathname, useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useEffect } from 'react'
|
||||
import 'react-native-reanimated'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { View, ActivityIndicator } from 'react-native'
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme'
|
||||
import { KeyboardProvider } from 'react-native-keyboard-controller'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { ModalPortal } from '@/components/ui'
|
||||
import '@/lib/i18n'
|
||||
import { useSession } from '@/lib/auth'
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
}
|
||||
|
||||
// 受保护的路由(需要登录才能访问)
|
||||
const PROTECTED_ROUTES = [
|
||||
'/(tabs)',
|
||||
'/channels',
|
||||
'/generateVideo',
|
||||
'/generationRecord',
|
||||
'/worksList',
|
||||
'/membership',
|
||||
'/changePassword',
|
||||
]
|
||||
|
||||
// 公开路由(无需登录)
|
||||
const PUBLIC_ROUTES = [
|
||||
'/auth',
|
||||
'/modal',
|
||||
'/searchTemplate',
|
||||
'/searchResults',
|
||||
'/searchWorks',
|
||||
'/searchWorksResults',
|
||||
'/templateDetail',
|
||||
'/terms',
|
||||
'/privacy',
|
||||
]
|
||||
|
||||
// 认证守卫组件
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
// 加载中不处理
|
||||
if (isPending) return
|
||||
|
||||
// 检查是否在受保护路由
|
||||
const isProtectedRoute = PROTECTED_ROUTES.some(route => pathname?.startsWith(route))
|
||||
|
||||
// 未登录访问受保护路由 -> 跳转到登录页
|
||||
if (isProtectedRoute && !session?.user) {
|
||||
router.replace('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录访问登录页 -> 跳转到首页
|
||||
if (pathname === '/auth' && session?.user) {
|
||||
router.replace('/(tabs)')
|
||||
return
|
||||
}
|
||||
}, [pathname, session, isPending, router])
|
||||
|
||||
// 加载中显示 loading
|
||||
if (isPending) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#090A0B' }}>
|
||||
<ActivityIndicator size="large" color="#9966FF" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 路由层
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<Providers>
|
||||
<Stack>
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="channels" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="membership" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="worksList" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<AuthGuard>
|
||||
<Stack>
|
||||
<Stack.Screen name="auth" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="channels" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="membership" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="worksList" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</AuthGuard>
|
||||
<StatusBar style="auto" />
|
||||
</Providers>
|
||||
)
|
||||
|
|
|
|||
76
app/auth.tsx
76
app/auth.tsx
|
|
@ -1,5 +1,75 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { View, StyleSheet, StatusBar as RNStatusBar } from 'react-native'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { AuthForm } from '@/components/blocks/AuthForm'
|
||||
import { useSession } from '@/lib/auth'
|
||||
|
||||
export default function Auth() {
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
export default () => {
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
// 如果用户已登录,跳转到首页
|
||||
if (session?.user) {
|
||||
router.replace('/(tabs)' as any)
|
||||
}
|
||||
}, [session, router])
|
||||
|
||||
// 会话加载中显示空白
|
||||
if (isPending) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar style="light" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果已登录,不显示内容(等待跳转)
|
||||
if (session?.user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
router.replace('/(tabs)' as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={['#1a1a2e', '#16213e', '#0f3460']}
|
||||
locations={[0, 0.5, 1]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.background}
|
||||
>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
<View style={styles.content}>
|
||||
<AuthForm onSuccess={handleSuccess} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import React, { useState } from "react";
|
||||
import { View, TextInput, ActivityIndicator } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TextInput } from "react-native";
|
||||
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
||||
import { Block } from "../ui";
|
||||
import { Button } from "../ui/button";
|
||||
import Text from "../ui/Text";
|
||||
import { authClient, useSession } from "../../lib/auth";
|
||||
import { Block } from "../ui";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
type AuthResponse = { data: unknown; error: { message: string } | null };
|
||||
type SignInFn = (params: { username: string; password: string }) => Promise<AuthResponse>;
|
||||
type SignUpFn = (params: { email: string; password: string; name: string }) => Promise<AuthResponse>;
|
||||
|
||||
const signIn = authClient.signIn as unknown as { username: SignInFn };
|
||||
const signUp = authClient.signUp as unknown as { email: SignUpFn };
|
||||
const signIn = authClient.signIn
|
||||
const signUp = authClient.signUp
|
||||
|
||||
interface AuthFormProps {
|
||||
mode?: AuthMode;
|
||||
|
|
@ -32,6 +28,18 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||
|
||||
const isLogin = currentMode === "login";
|
||||
|
||||
// 根据错误代码获取翻译后的错误信息
|
||||
const getErrorMessage = (error: { message: string; code?: string }): string => {
|
||||
if (error.code) {
|
||||
const translated = t(`authForm.errors.${error.code}`);
|
||||
// 如果翻译不存在,返回原始 message
|
||||
if (!translated.includes('authForm.errors')) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
return error.message;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError(t("authForm.fillCompleteInfo"));
|
||||
|
|
@ -48,11 +56,31 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||
|
||||
try {
|
||||
if (isLogin) {
|
||||
const res = await signIn.username({ username, password });
|
||||
if (res.error) throw new Error(res.error.message);
|
||||
await signIn.username({ username, password }, {
|
||||
onSuccess: async (ctx) => {
|
||||
const authToken = ctx.response.headers.get('set-auth-token')
|
||||
if (authToken) {
|
||||
setAuthToken(authToken)
|
||||
}
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(getErrorMessage(ctx.error));
|
||||
console.error(`[LOGIN] username login error`, ctx)
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const res = await signUp.email({ email, password, name: username });
|
||||
if (res.error) throw new Error(res.error.message);
|
||||
await signUp.email({ email, password, name: username }, {
|
||||
onSuccess: async (ctx) => {
|
||||
const authToken = ctx.response.headers.get('set-auth-token')
|
||||
if (authToken) {
|
||||
setAuthToken(authToken)
|
||||
}
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(getErrorMessage(ctx.error));
|
||||
console.error(`[LOGIN] username login error`, ctx)
|
||||
},
|
||||
});
|
||||
}
|
||||
onSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
|
|
@ -140,3 +168,4 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||
}
|
||||
|
||||
export { useSession };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
import Text from './Text'
|
||||
import Block from './Block'
|
||||
import { Text as RNText } from 'react-native'
|
||||
|
||||
import { ActivityIndicator } from 'react-native'
|
||||
|
||||
|
|
@ -65,20 +66,35 @@ const Toast = (function () {
|
|||
// })
|
||||
|
||||
let toastTimer: number | null = null
|
||||
const show = (params?: ShowParams): void => {
|
||||
const { renderContent, title, duration = 2000, hideBackdrop = true } = params || {}
|
||||
const show = (params?: ShowParams | string): void => {
|
||||
// 兼容字符串参数
|
||||
const options: ShowParams = typeof params === 'string' ? { title: params } : (params || {})
|
||||
const { renderContent, title, duration = 4000, hideBackdrop = true } = options
|
||||
hide()
|
||||
|
||||
const renderBody = (): ReactNode => {
|
||||
if (renderContent) {
|
||||
return renderContent()
|
||||
}
|
||||
return title && <Text className="text-[16px] text-white">{title}</Text>
|
||||
return title && <RNText style={{ fontSize: 18, fontWeight: '600', color: '#ffffff' }}>{title}</RNText>
|
||||
}
|
||||
|
||||
; (global as any).toast?.show(
|
||||
<Block className="z-[9999] flex items-center justify-center">
|
||||
<Block className="relative mx-[20px] mt-[-40px] rounded-[2px] bg-black/85 px-[12px] py-[8px]">
|
||||
<Block className="z-[9999] flex items-center justify-center" style={{ position: 'fixed', top: '30%', left: 0, right: 0 }}>
|
||||
<Block style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
marginHorizontal: 40,
|
||||
minHeight: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
}}>
|
||||
{renderBody()}
|
||||
</Block>
|
||||
</Block>,
|
||||
|
|
|
|||
21
lib/auth.ts
21
lib/auth.ts
|
|
@ -12,13 +12,17 @@ import {
|
|||
usernameClient,
|
||||
} from 'better-auth/client/plugins'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import Constants from 'expo-constants'
|
||||
|
||||
import { fetchWithLogger } from './fetch-logger'
|
||||
import { fetchWithLogger, TOKEN_KEY } from './fetch-logger'
|
||||
import type { Subscription } from './plugins/stripe'
|
||||
import { stripeClient } from './plugins/stripe-plugin'
|
||||
import { storage } from './storage'
|
||||
import type { ApiError } from './types'
|
||||
|
||||
// 商户ID配置 - 从环境变量或应用配置中读取
|
||||
const MERCHANT_ID = Constants.expoConfig?.extra?.merchantId || ''
|
||||
|
||||
export interface CreditBalance {
|
||||
tokenUsage: number
|
||||
remainingTokenBalance: number
|
||||
|
|
@ -72,10 +76,11 @@ export interface ISubscription {
|
|||
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
|
||||
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
|
||||
}
|
||||
export const getAuthToken = async () => (await storage.getItem('bestaibest.better-auth.session_token')) || ''
|
||||
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
|
||||
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''
|
||||
|
||||
export const setAuthToken = async (token: string) => {
|
||||
await storage.setItem(`bestaibest.better-auth.session_token`, token)
|
||||
await storage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: 'https://api.mixvideo.bowong.cc',
|
||||
|
|
@ -83,6 +88,12 @@ export const authClient = createAuthClient({
|
|||
storage,
|
||||
scheme: 'duooomi',
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// x-ownerid: 商户ID(如果后端需要)
|
||||
// 如果 MERCHANT_ID 为空,则不添加此 header
|
||||
...(MERCHANT_ID && { 'x-ownerid': MERCHANT_ID }),
|
||||
},
|
||||
auth: {
|
||||
type: 'Bearer',
|
||||
token: async () => {
|
||||
|
|
@ -111,4 +122,8 @@ export const authClient = createAuthClient({
|
|||
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } =
|
||||
authClient
|
||||
|
||||
// 导出 loomart API(来自 createSkerClientPlugin)
|
||||
export const loomart = Reflect.get(authClient, 'loomart')
|
||||
|
||||
// 导出 subscription API
|
||||
export const subscription: ISubscription = Reflect.get(authClient, 'subscription')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { router } from 'expo-router'
|
||||
import { router } from 'expo-router';
|
||||
import Toast from '@/components/ui/Toast';
|
||||
|
||||
import { storage } from './storage'
|
||||
import { storage } from './storage';
|
||||
|
||||
// 统一的 Token 存储键名(与 lib/auth.ts 保持一致)
|
||||
export const TOKEN_KEY = 'bestaibest.better-auth.session_token';
|
||||
|
||||
interface FetchLoggerOptions {
|
||||
enableLogging?: boolean
|
||||
|
|
@ -18,6 +22,9 @@ const defaultOptions: FetchLoggerOptions = {
|
|||
|
||||
const originalFetch = global.fetch
|
||||
|
||||
// 防止重复跳转的标志
|
||||
let isRedirecting = false
|
||||
|
||||
export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
||||
const config = { ...defaultOptions, ...options }
|
||||
|
||||
|
|
@ -27,24 +34,79 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
|||
const method = init?.method || 'GET'
|
||||
|
||||
try {
|
||||
const token = await storage.getItem('token')
|
||||
// 使用统一的 Token 存储键
|
||||
const token = await storage.getItem(TOKEN_KEY)
|
||||
|
||||
if (token) {
|
||||
init = {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
// 确保 headers 是一个对象
|
||||
const headers: Record<string, string> = {}
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.forEach((value, key) => {
|
||||
headers[key] = value
|
||||
})
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers.forEach(([key, value]) => {
|
||||
headers[key] = value as string
|
||||
})
|
||||
} else {
|
||||
Object.assign(headers, init.headers)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await originalFetch(input, init)
|
||||
// 添加 Bearer Token
|
||||
if (token) {
|
||||
headers['authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
// 确保 Content-Type 正确设置
|
||||
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
// 序列化请求体(如果是对象)
|
||||
let body = init?.body
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof URLSearchParams)) {
|
||||
body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await originalFetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
...(body !== undefined && { body }),
|
||||
})
|
||||
|
||||
// 401 未授权处理
|
||||
if (response.status === 401 && !isRedirecting) {
|
||||
// 检查是否是登录/注册接口的 401 错误(用户名密码错误)
|
||||
const isAuthEndpoint = url.includes('/sign-in/') || url.includes('/sign-up/')
|
||||
|
||||
// 如果是认证接口的 401,不处理,让错误传递到组件
|
||||
if (isAuthEndpoint) {
|
||||
console.log('🔐 认证接口返回 401,不处理')
|
||||
return response
|
||||
}
|
||||
|
||||
// 其他接口的 401 才认为是 Token 过期
|
||||
console.warn('🔐 401 未授权,跳转到登录页')
|
||||
// router.replace('/auth')
|
||||
router.push('/auth')
|
||||
isRedirecting = true
|
||||
|
||||
// 显示 Toast 提示(安全检查)
|
||||
try {
|
||||
Toast.show('登录已过期,请重新登录')
|
||||
} catch (error) {
|
||||
console.warn('[Toast] 显示失败:', error)
|
||||
}
|
||||
|
||||
// 清除过期 Token
|
||||
await storage.removeItem(TOKEN_KEY)
|
||||
|
||||
// 跳转到登录页(使用 replace 避免返回栈问题)
|
||||
router.replace('/auth')
|
||||
|
||||
// 延迟重置标志(防止并发请求导致重复跳转)
|
||||
setTimeout(() => {
|
||||
isRedirecting = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -1,15 +1,28 @@
|
|||
import * as SecureStore from "expo-secure-store";
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export const storage = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return await SecureStore.getItemAsync(key);
|
||||
try {
|
||||
return await AsyncStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error getting item "${key}":`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
try {
|
||||
await AsyncStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error setting item "${key}":`, error);
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
try {
|
||||
await AsyncStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error removing item "${key}":`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,36 @@
|
|||
/**
|
||||
* Web平台存储实现 (使用 localStorage)
|
||||
* Native平台会自动使用 storage.native.ts (使用 AsyncStorage)
|
||||
*/
|
||||
|
||||
declare const window: any;
|
||||
|
||||
export const storage = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
try {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error getting item "${key}":`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, value);
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error setting item "${key}":`, error);
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(key);
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Error removing item "${key}":`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -181,7 +181,18 @@
|
|||
"loginFailed": "Login failed",
|
||||
"registerFailed": "Registration failed",
|
||||
"noAccountRegister": "No account? Register",
|
||||
"haveAccountLogin": "Already have an account? Login"
|
||||
"haveAccountLogin": "Already have an account? Login",
|
||||
"errors": {
|
||||
"INVALID_USERNAME_OR_PASSWORD": "Invalid username or password",
|
||||
"USER_NOT_FOUND": "User not found",
|
||||
"EMAIL_ALREADY_EXISTS": "Email already exists",
|
||||
"USERNAME_ALREADY_EXISTS": "Username already taken",
|
||||
"WEAK_PASSWORD": "Password is too weak",
|
||||
"INVALID_EMAIL": "Invalid email format",
|
||||
"VALIDATION_ERROR": "Validation error",
|
||||
"NETWORK_ERROR": "Network connection failed",
|
||||
"UNKNOWN_ERROR": "Unknown error, please try again later"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,18 @@
|
|||
"loginFailed": "登录失败",
|
||||
"registerFailed": "注册失败",
|
||||
"noAccountRegister": "没有账号?去注册",
|
||||
"haveAccountLogin": "已有账号?去登录"
|
||||
"haveAccountLogin": "已有账号?去登录",
|
||||
"errors": {
|
||||
"INVALID_USERNAME_OR_PASSWORD": "用户名或密码错误",
|
||||
"USER_NOT_FOUND": "用户不存在",
|
||||
"EMAIL_ALREADY_EXISTS": "邮箱已被注册",
|
||||
"USERNAME_ALREADY_EXISTS": "用户名已被占用",
|
||||
"WEAK_PASSWORD": "密码强度不够",
|
||||
"INVALID_EMAIL": "邮箱格式不正确",
|
||||
"VALIDATION_ERROR": "输入信息格式错误",
|
||||
"NETWORK_ERROR": "网络连接失败",
|
||||
"UNKNOWN_ERROR": "未知错误,请稍后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue