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