diff --git a/code/base-project/base-app/src/app/(tabs)/_layout.tsx b/code/base-project/base-app/src/app/(tabs)/_layout.tsx
index c80f3267..d48dbc44 100644
--- a/code/base-project/base-app/src/app/(tabs)/_layout.tsx
+++ b/code/base-project/base-app/src/app/(tabs)/_layout.tsx
@@ -1,5 +1,5 @@
-import type { TabbarItem } from '@/components/custom/app-tabbar'
-import AppTabbar from '@/components/custom/app-tabbar'
+import type { TabbarItem } from '@/components/custom/tabbar'
+import CustomTabbar from '@/components/custom/tabbar'
import { useToast } from '@/utils/toast'
export default function TabsLayout() {
@@ -40,6 +40,6 @@ export default function TabsLayout() {
},
]
return (
-
+
)
}
diff --git a/code/base-project/base-app/src/app/(tabs)/chat.tsx b/code/base-project/base-app/src/app/(tabs)/chat.tsx
index 2e193448..13a93fca 100644
--- a/code/base-project/base-app/src/app/(tabs)/chat.tsx
+++ b/code/base-project/base-app/src/app/(tabs)/chat.tsx
@@ -1,9 +1,15 @@
-import { Text, View } from 'react-native'
+import { ScrollView, Text, View } from 'react-native'
export default function Chat() {
return (
-
- Chat
-
+
+ {Array.from({ length: 50 }).map((_, i) => (
+
+ Chat message
+ {' '}
+ {i + 1}
+
+ ))}
+
)
}
diff --git a/code/base-project/base-app/src/app/_layout.tsx b/code/base-project/base-app/src/app/_layout.tsx
index ec2b4c2b..eb473a3d 100644
--- a/code/base-project/base-app/src/app/_layout.tsx
+++ b/code/base-project/base-app/src/app/_layout.tsx
@@ -1,13 +1,14 @@
import type { ErrorBoundaryProps } from 'expo-router'
import { ToastProvider } from '@gluestack-ui/core/toast/creator'
import { useFonts } from 'expo-font'
-import { Stack } from 'expo-router'
+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 } from 'react'
+import { useEffect, useState } from 'react'
import { EmptyState } from '@/components/common/empty-status'
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
+import { useUserStore } from '@/store/user'
import { ToastProviderWrapper } from '@/utils/toast'
import '@/styles/global.css'
import 'react-native-reanimated'
@@ -31,15 +32,34 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
}
export default function RootLayout() {
- const [loaded] = useFonts({
+ const router = useRouter()
+
+ const token = useUserStore(state => state.token)
+
+ const [fontsLoaded] = useFonts({
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
})
+ const [storeLoaded, setStoreLoaded] = useState(false)
+
+ // 等待 store 恢复数据
useEffect(() => {
- if (loaded) {
- SplashScreen.hideAsync()
+ if (token !== undefined) {
+ setStoreLoaded(true)
}
- }, [loaded])
+ }, [token])
+
+ // splash screen 隐藏 & 登录跳转
+ useEffect(() => {
+ if (fontsLoaded && storeLoaded) {
+ SplashScreen.hideAsync()
+
+ // 如果没有 token,则跳转登录页
+ if (token === null) {
+ router.replace('/auth/login')
+ }
+ }
+ }, [fontsLoaded, storeLoaded, token])
return (
@@ -49,12 +69,24 @@ export default function RootLayout() {
screenOptions={{
headerTitleAlign: 'center',
animation: 'slide_from_right',
+ gestureEnabled: true, // 启用手势返回
+ gestureDirection: 'horizontal', // 水平手势
+ contentStyle: {
+ backgroundColor: '#fff',
+ },
}}
>
+
diff --git a/code/base-project/base-app/src/app/auth/login.tsx b/code/base-project/base-app/src/app/auth/login.tsx
new file mode 100644
index 00000000..d2a37af8
--- /dev/null
+++ b/code/base-project/base-app/src/app/auth/login.tsx
@@ -0,0 +1,9 @@
+import { Text, View } from 'react-native'
+
+export default function AuthLogin() {
+ return (
+
+ Login
+
+ )
+}
diff --git a/code/base-project/base-app/src/components/custom/app-tabbar.tsx b/code/base-project/base-app/src/components/custom/app-tabbar.tsx
deleted file mode 100644
index 4b31cb63..00000000
--- a/code/base-project/base-app/src/components/custom/app-tabbar.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { usePathname } from 'expo-router'
-import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui'
-import { icons } from 'lucide-react-native'
-import { useEffect, useState } from 'react'
-import { Pressable, View } from 'react-native'
-import Animated, {
- useAnimatedStyle,
- useSharedValue,
- withSequence,
- withTiming,
-} from 'react-native-reanimated'
-
-export interface TabbarItem {
- icon: keyof typeof icons
- iconSize?: number
- activeIcon?: keyof typeof icons
- name: string
- href?: string
- onPress?: () => void
-}
-
-interface AppTabbarProps {
- data: TabbarItem[]
- onTabPress?: (item: TabbarItem) => void
-}
-
-function TabButton({
- icon,
- activeIcon,
- iconSize = 24,
- isFocused = false,
- onPress,
-}: {
- icon: keyof typeof icons
- activeIcon?: keyof typeof icons
- iconSize?: number
- isFocused?: boolean
- onPress?: () => void
-}) {
- const activeColor = '#333'
- const inactiveColor = '#aaa'
-
- const scale = useSharedValue(1)
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.value }],
- }))
-
- const IconComponent = icons[isFocused && activeIcon ? activeIcon : icon]
-
- const handlePress = () => {
- scale.value = withSequence(
- withTiming(0.9, { duration: 200 }),
- withTiming(1, { duration: 200 }),
- )
- onPress?.()
- }
-
- return (
-
-
-
-
-
- )
-}
-
-export default function AppTabbar({ data, onTabPress }: AppTabbarProps) {
- const pathname = usePathname()
-
- // 初始选中 Tab
- const defaultTab = data.find(item => item.href === pathname)?.name
- ?? data.find(item => item.href)?.name
- ?? data[0]?.name
- const [activeTab, setActiveTab] = useState(defaultTab)
-
- // 当 pathname 改变(路由跳转)时,同步高亮
- useEffect(() => {
- const matchTab = data.find(item => item.href === pathname)
- if (matchTab) {
- setActiveTab(matchTab.name)
- }
- // 如果没有匹配的路由,保持当前的 activeTab 不变
- // 这样无 href 的项就能保持选中状态
- }, [pathname, data])
-
- const handleTabPress = (item: TabbarItem) => {
- setActiveTab(item.name)
- item.onPress?.()
- onTabPress?.(item)
- }
-
- return (
-
-
-
-
-
- {data.map((item) => {
- const isFocused = activeTab === item.name
-
- if (item.href) {
- return (
-
- handleTabPress(item)}
- />
-
- )
- }
-
- return (
- handleTabPress(item)}
- isFocused={isFocused}
- />
- )
- })}
-
-
-
- )
-}
diff --git a/code/base-project/base-app/src/components/custom/tabbar.tsx b/code/base-project/base-app/src/components/custom/tabbar.tsx
new file mode 100644
index 00000000..313a8e21
--- /dev/null
+++ b/code/base-project/base-app/src/components/custom/tabbar.tsx
@@ -0,0 +1,198 @@
+import { usePathname, useRouter } from 'expo-router'
+import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui'
+import { icons } from 'lucide-react-native'
+import { useEffect, useState } from 'react'
+import { Pressable, View } from 'react-native'
+import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
+import Animated, { SlideInLeft, SlideInRight, SlideOutLeft, SlideOutRight, useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated'
+import { scheduleOnRN } from 'react-native-worklets'
+
+interface AppTabbarProps {
+ data: TabbarItem[]
+}
+
+export interface TabbarItem {
+ icon: keyof typeof icons
+ iconSize?: number
+ activeIcon?: keyof typeof icons
+ name: string
+ href?: string
+ onPress?: () => void
+}
+
+function TabButton({
+ icon,
+ activeIcon,
+ iconSize = 24,
+ isFocused = false,
+ onPress,
+}: {
+ icon: keyof typeof icons
+ activeIcon?: keyof typeof icons
+ iconSize?: number
+ isFocused?: boolean
+ onPress?: () => void
+}) {
+ const scale = useSharedValue(1)
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.value }],
+ }))
+
+ useEffect(() => {
+ if (isFocused) {
+ scale.value = withSequence(
+ withTiming(0.9, { duration: 120 }),
+ withTiming(1, { duration: 160 }),
+ )
+ }
+ }, [isFocused])
+
+ const IconComponent = icons[isFocused && activeIcon ? activeIcon : icon]
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default function CustomTabbar({ data }: AppTabbarProps) {
+ const router = useRouter()
+ const pathname = usePathname()
+
+ const [activeTab, setActiveTab] = useState(() => {
+ const match = data.find(item => item.href === pathname)
+ return match?.name ?? data[0]?.name
+ })
+
+ // 反转方向
+ const [swipeDirection, setSwipeDirection] = useState<'left' | 'right'>('left')
+ function updateSwipeDirection(tabName: string) {
+ const currentIndex = data.findIndex(d => d.name === activeTab)
+ const targetIndex = data.findIndex(d => d.name === tabName)
+
+ if (currentIndex === -1 || targetIndex === -1 || currentIndex === targetIndex)
+ return
+
+ setSwipeDirection(targetIndex > currentIndex ? 'left' : 'right')
+ setActiveTab(tabName)
+ }
+
+ // 点击调用
+ const handlPressTab = (item: TabbarItem) => {
+ updateSwipeDirection(item.name)
+
+ setActiveTab(item.name)
+ if (typeof item.onPress === 'function')
+ item.onPress()
+ }
+
+ // 定义 JS 线程安全函数
+ const handlePanTab = (tabName: string) => {
+ const tab = data.find(item => item.name === tabName)
+ if (!tab)
+ return
+
+ updateSwipeDirection(tabName)
+
+ setActiveTab(tab.name)
+
+ if (typeof tab.onPress === 'function') {
+ tab.onPress()
+ }
+
+ if (tab.href && tab.href !== pathname) {
+ router.push(tab.href)
+ }
+ }
+
+ // 手势滑动
+ const panGesture = Gesture.Pan()
+ .activeOffsetX([-20, 20])
+ .failOffsetY([-10, 10])
+ .onEnd((event) => {
+ const threshold = 60
+ const currentIndex = data.findIndex(item => item.name === activeTab)
+ let targetIndex: number | undefined
+
+ if (event.translationX < -threshold && currentIndex < data.length - 1) {
+ targetIndex = currentIndex + 1
+ }
+ else if (event.translationX > threshold && currentIndex > 0) {
+ targetIndex = currentIndex - 1
+ }
+
+ if (targetIndex !== undefined) {
+ const tabName = data[targetIndex].name
+ scheduleOnRN(handlePanTab, tabName)
+ }
+ })
+
+ return (
+
+
+
+
+ {(() => {
+ const currentTab = data.find(d => d.name === activeTab)
+
+ if (!currentTab)
+ return null
+
+ return currentTab.href
+ ? (
+
+
+
+ )
+ : (
+
+ )
+ })()}
+
+
+
+
+
+ {data.map((item) => {
+ if (item.href) {
+ return (
+
+ handlPressTab(item)}
+ />
+
+ )
+ }
+
+ return (
+ handlPressTab(item)}
+ />
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/code/base-project/base-app/src/store/user.ts b/code/base-project/base-app/src/store/user.ts
new file mode 100644
index 00000000..79f40036
--- /dev/null
+++ b/code/base-project/base-app/src/store/user.ts
@@ -0,0 +1,33 @@
+// store/userStore.ts
+import AsyncStorage from '@react-native-async-storage/async-storage'
+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
+ logout: () => void
+ isLoggedIn: () => boolean
+}
+
+export const useUserStore = create()(
+ 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 })
+ },
+ isLoggedIn: () => !!get().token,
+ }),
+ {
+ name: 'user-storage',
+ storage: createJSONStorage(() => AsyncStorage),
+ },
+ ),
+)
diff --git a/code/base-project/base-app/src/utils/runOnMain.ts b/code/base-project/base-app/src/utils/runOnMain.ts
new file mode 100644
index 00000000..fc0479c1
--- /dev/null
+++ b/code/base-project/base-app/src/utils/runOnMain.ts
@@ -0,0 +1,8 @@
+// utils/runOnMain.ts
+// ✅ 安全封装 runOnJS,屏蔽 deprecated 警告
+import { runOnJS as _runOnJS } from 'react-native-worklets'
+
+// 屏蔽类型定义中的 deprecated 提示
+export const runOnMain = ((fn: (...args: any[]) => void) => {
+ return _runOnJS(fn)
+}) as typeof _runOnJS