This commit is contained in:
oneao committed 2025-11-06 17:32:51 +08:00
1 parent 6849579652
commit d60be4a75b
24 files changed
+463 -179

No files matched your search

@@ -12,5 +12,6 @@ export default antfu({
'no-console': 'off', // 关闭 console 报错
'node/prefer-global/process': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-useless-catch': 'off',
},
})
+5 -15
View File
@@ -5,7 +5,7 @@ import { Stack, useRouter } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import { RefreshCw } from 'lucide-react-native'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import EmptyState from '@/components/common/EmptyStatus'
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
import { useUserStore } from '@/store/user'
@@ -33,33 +33,23 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
export default function RootLayout() {
const router = useRouter()
const token = useUserStore(state => state.token)
const userStore = useUserStore()
const [fontsLoaded] = useFonts({
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
})
const [storeLoaded, setStoreLoaded] = useState(false)
// 等待 store 恢复数据
useEffect(() => {
if (token !== undefined) {
setStoreLoaded(true)
}
}, [token])
// splash screen 隐藏 & 登录跳转
useEffect(() => {
if (fontsLoaded && storeLoaded) {
if (fontsLoaded) {
SplashScreen.hideAsync()
// 如果没有 token,则跳转登录页
if (token === null) {
if (!userStore.isLoggedIn()) {
router.replace('/auth')
}
}
}, [fontsLoaded, storeLoaded, token])
}, [fontsLoaded])
return (
<GluestackUIProvider>
@@ -1,6 +1,9 @@
import type { StepLayoutItem } from '@/components/custom/StepLayout'
import { Slot } from 'expo-router'
import { Slot, Stack, useRouter } from 'expo-router'
import { Image, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import StepLayout from '@/components/custom/StepLayout'
import CustomTabbar from '@/components/custom/Tabbar'
const steps: StepLayoutItem[] = [
{
@@ -22,5 +25,6 @@ export default function AuthLayout() {
<StepLayout steps={steps}>
<Slot />
</StepLayout>
)
}
@@ -36,17 +36,20 @@ export default function AuthLogin() {
setForm(prev => ({ ...prev, [field]: value }))
}
const defaultLogin = () => {
alovaInstance.Post('/auth/login/default', {
alovaInstance.Post('/auth/test', {
title: 'foo',
body: 'bar',
userId: 1,
}).then((res) => {
console.log(res)
})
.then((res) => {
console.log('成功返回:', res)
})
.catch((err) => {
console.log('请求失败或未授权:', err)
})
}
return (
<View className="flex flex-col items-center px-6 h-full">
<Image
source={require('@/static/images/logo.png')}
@@ -42,7 +42,7 @@ export default function AuthRegister() {
return (
<ScrollView ref={scrollRef} style={{ flex: 1 }}>
<View className="flex flex-col items-center px-6">
<View className="flex flex-col items-center px-6 bg-white ">
<Image
source={require('@/static/images/logo.png')}
style={{ width: 120, height: 120 }}
@@ -1,7 +1,7 @@
import { Ionicons } from '@expo/vector-icons'
import { usePathname, useRouter } from 'expo-router'
import { Image, Pressable, ScrollView, Text, View } from 'react-native'
import Animated, { SlideInRight } from 'react-native-reanimated'
import { Image, Pressable, Text, View } from 'react-native'
import Animated, { SlideInLeft, SlideInRight } from 'react-native-reanimated'
import { SafeAreaView } from 'react-native-safe-area-context'
export interface StepLayoutItem {
@@ -31,11 +31,13 @@ export default function StepLayout({ steps, children, showHearder = true }: Step
// 是否显示header
const headerVisible = showHearder !== false && currentStep.showHearder !== false
const enteringAnimation = canGoBack ? SlideInRight.duration(300) : SlideInLeft.duration(300)
return (
<SafeAreaView style={{ flex: 1 }}>
<Animated.View
key={pathname}
entering={SlideInRight.duration(300)}
entering={enteringAnimation}
style={{ flex: 1 }}
>
{headerVisible && (
@@ -44,10 +46,7 @@ export default function StepLayout({ steps, children, showHearder = true }: Step
onPress={canGoBack ? (currentStep.onBack ?? (() => router.back())) : undefined}
style={{ opacity: canGoBack ? 1 : 0 }}
>
<Ionicons
name="arrow-back"
size={22}
/>
<Ionicons name="arrow-back" size={22} />
</Pressable>
<Text className="flex-1 text-center text-lg font-semibold">
Whitespace-only changes.
+101 -12
View File
@@ -1,21 +1,110 @@
import type { Method } from 'alova'
import type { AxiosError } from 'axios'
import { axiosRequestAdapter } from '@alova/adapter-axios'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import adapterFetch from 'alova/fetch'
import { useToast } from '@/utils/toast'
import { router } from 'expo-router'
import { userStore } from '@/store/user'
import { toast } from '@/utils/toast'
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication({
// ...
})
enum HttpCode {
Success = 200,
Unauthorized = 401,
RefreshToken = 402,
Fail = 500,
}
const WhiteList = [
'/auth/login/**',
'/auth/register/**',
'/auth/refresh/token',
]
function isWhiteListed(url: string) {
return WhiteList.some((pattern) => {
if (pattern.endsWith('/**')) {
const base = pattern.slice(0, -3)
return url.startsWith(base)
}
return url === pattern
})
}
const alovaInstance = createAlova({
// baseURL: 'http://192.168.1.136:8085/app',
baseURL: 'http://192.168.5.6:8085/app',
baseURL: 'http://192.168.1.136:8085/app',
requestAdapter: axiosRequestAdapter(),
responded(response) {
// response自动被推断为AxiosResponse类型
return response.data
responded: {
onSuccess: async (response, method: Method) => {
const resData = response.data as ResponseData<any>
if (resData.code === HttpCode.Success) {
return resData.data
}
// Access Token 失效 → 重新登录
if (resData.code === HttpCode.Unauthorized) {
console.log('===到这一步了===')
router.replace('/auth/login')
return
}
// Access Token 过期但 Refresh Token 有效 → 刷新 token 并重试
if (resData.code === HttpCode.RefreshToken) {
try {
const refreshToken = userStore.getState().userInfo?.refreshToken
if (!refreshToken) {
router.replace('/auth/login')
return
}
// 调用刷新接口
const res = await alovaInstance.Get<ResponseData<string>>('/auth/refresh/token', {
headers: {
refreshToken,
},
})
const newToken = res.data
// 更新 store
userStore.getState().setUserInfo({
...userStore.getState().userInfo!,
token: newToken,
})
// 重试原请求
method.config.headers.token = newToken
await method.send()
return
}
catch (err) {
console.log('===到这一步了===')
router.replace('/auth/login')
return Promise.reject(err)
}
}
// 其他错误
return Promise.reject(resData.message || '请求失败')
},
onError: (error: AxiosError, method) => {
console.log(error.status)
return Promise.reject(error)
},
},
beforeRequest(method) {
if (isWhiteListed(method.url))
return
if (!userStore.getState().isLoggedIn()) {
toast.show({
title: '尚未登录',
})
router.replace('/auth/login')
return
}
method.config.headers.token = userStore.getState().userInfo?.token ?? ''
},
})
+9 -10
View File
@@ -4,26 +4,22 @@ import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
interface UserState {
userInfo: { userId: string, username: string } | null
token: string | null
setUserInfo: (userInfo: UserState['userInfo']) => void
setToken: (token: string) => void
userInfo: UserInfo | null
setUserInfo: (userInfo: UserInfo | null) => void
logout: () => void
isLoggedIn: () => boolean
}
export const useUserStore = create<UserState>()(
const useUserStore = create<UserState>()(
persist(
(set, get) => ({
userInfo: null,
token: null,
setUserInfo: userInfo => set({ userInfo }),
setToken: token => set({ token }),
logout: () => {
console.log('当前 token:', get().token)
set({ userInfo: null, token: null })
console.log('当前 token:', get().userInfo?.token)
set({ userInfo: null })
},
isLoggedIn: () => !!get().token,
isLoggedIn: () => !!get().userInfo?.token,
}),
{
name: 'user-storage',
@@ -31,3 +27,6 @@ export const useUserStore = create<UserState>()(
},
),
)
const userStore = useUserStore
export { userStore, useUserStore }
+20
View File
@@ -0,0 +1,20 @@
export {}
declare global {
interface ResponseData<T> {
trackId: string
code: number
message: string
data: T
}
interface UserInfo {
account: string
id: string
seqNo: number
username: string
gender: number
token?: string
refreshToken?: string
}
}
+81 -13
View File
@@ -1,5 +1,5 @@
import type { IToastProps } from '@gluestack-ui/core/lib/esm/toast/creator/types'
import React, { useRef } from 'react'
import React, { createContext, useContext, useRef } from 'react'
import { Toast, ToastDescription, ToastTitle, useToast as useGluestackToast } from '@/components/ui/toast'
type ToastVariant = 'solid' | 'outline'
@@ -12,28 +12,74 @@ interface ShowToastOptions extends IToastProps {
action?: ToastAction
}
// 创建全局 toast 实例
let globalToast: any = null
// 创建 Context 用于在组件内部使用
const ToastContext = createContext<ReturnType<typeof useGluestackToast> | null>(null)
export function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
const toast = useGluestackToast()
const toastRef = useRef(toast)
toastRef.current = toast
return <>{children}</>
// 设置全局实例
React.useEffect(() => {
globalToast = {
show: (options: ShowToastOptions) => {
const id = Math.random().toString()
toast.show({
id,
placement: options.placement ?? 'top',
duration: options.duration !== undefined ? options.duration : 3000,
avoidKeyboard: options.avoidKeyboard ?? false,
containerStyle: options.containerStyle,
onCloseComplete: options.onCloseComplete,
render: options.render
? () => options.render!({ id })
: ({ id }) => (
<Toast
nativeID={`toast-${id}`}
variant={options.variant ?? 'solid'}
action={options.action ?? 'muted'}
>
{options.title && <ToastTitle className="font-blod">{options.title}</ToastTitle>}
{options.description && <ToastDescription>{options.description}</ToastDescription>}
</Toast>
),
})
return id
},
close: (id: string) => {
toast.close(id)
},
closeAll: () => {
toast.closeAll()
},
isActive: (id: string) => toast.isActive(id),
}
}, [toast])
return (
<ToastContext.Provider value={toast}>
{children}
</ToastContext.Provider>
)
}
/**
* 自定义 Hook,用法:
* const toast = useToast();
* toast.show({ ... });
* toast.close(id);
* toast.closeAll();
* 自定义 Hook,用于 React 组件内部
*/
export function useToast() {
const toast = useGluestackToast()
const toast = useContext(ToastContext)
if (!toast) {
throw new Error('useToast must be used within a ToastProviderWrapper')
}
return {
show: (options: ShowToastOptions) => {
const id = Math.random().toString()
toast.show({
id,
placement: options.placement ?? 'top',
@@ -54,18 +100,40 @@ export function useToast() {
</Toast>
),
})
return id
},
close: (id: string) => {
toast.close(id)
},
closeAll: () => {
toast.closeAll()
},
isActive: (id: string) => toast.isActive(id),
}
}
/**
* 全局 toast 实例,可在组件外部使用
*/
export const toast = {
show: (options: ShowToastOptions) => {
if (!globalToast) {
console.warn('Toast not initialized. Make sure ToastProviderWrapper is rendered.')
return ''
}
return globalToast.show(options)
},
close: (id: string) => {
if (globalToast) {
globalToast.close(id)
}
},
closeAll: () => {
if (globalToast) {
globalToast.closeAll()
}
},
isActive: (id: string) => {
return globalToast ? globalToast.isActive(id) : false
},
}
+1 -1
View File
@@ -12,7 +12,7 @@
"include": [
"./**/*.ts",
"./**/*.tsx",
"./src/types/*.d.ts",
"src/**/*.d.ts",
"nativewind-env.d.ts"
],
"exclude": [