From 2ddb9cadd8277b36736387586923f92cd1eaa7bf Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 14:21:31 +0800 Subject: [PATCH] refactor(App, navigation): migrate to Expo Router and clean up navigation structure - Updated App entry point to utilize Expo Router, simplifying the navigation setup. - Removed legacy navigation components and services to streamline the codebase. - Adjusted package.json to reflect the new entry point and updated dependencies for compatibility. - Enhanced overall application structure by consolidating navigation logic and improving maintainability. --- App.tsx | 136 +---- app/(app)/(tabs)/_layout.tsx | 96 +++ app/(app)/(tabs)/home/_layout.tsx | 5 + app/(app)/(tabs)/home/index.tsx | 5 + app/(app)/(tabs)/home/search.tsx | 5 + app/(app)/(tabs)/messages/_layout.tsx | 5 + app/(app)/(tabs)/messages/index.tsx | 5 + app/(app)/(tabs)/messages/notifications.tsx | 5 + app/(app)/(tabs)/profile/_layout.tsx | 36 ++ app/(app)/(tabs)/profile/account-security.tsx | 5 + app/(app)/(tabs)/profile/blocked-users.tsx | 5 + app/(app)/(tabs)/profile/bookmarks.tsx | 5 + app/(app)/(tabs)/profile/edit-profile.tsx | 5 + app/(app)/(tabs)/profile/index.tsx | 5 + app/(app)/(tabs)/profile/my-posts.tsx | 5 + .../(tabs)/profile/notification-settings.tsx | 5 + app/(app)/(tabs)/profile/settings.tsx | 5 + app/(app)/(tabs)/schedule/_layout.tsx | 17 + app/(app)/(tabs)/schedule/course.tsx | 5 + app/(app)/(tabs)/schedule/index.tsx | 5 + app/(app)/_layout.tsx | 19 + app/(app)/_layout.web.tsx | 28 + app/(app)/chat/[conversationId].tsx | 5 + app/(app)/chat/private-info.tsx | 5 + app/(app)/group/[groupId]/index.tsx | 5 + app/(app)/group/[groupId]/members.tsx | 5 + app/(app)/group/create.tsx | 5 + app/(app)/group/invite.tsx | 5 + app/(app)/group/join.tsx | 5 + app/(app)/group/request.tsx | 5 + app/(app)/posts/create.tsx | 5 + app/(app)/qrcode/login/[sessionId].tsx | 5 + app/(app)/users/[userId]/[type].tsx | 5 + app/(auth)/_layout.tsx | 5 + app/(auth)/forgot-password.tsx | 5 + app/(auth)/login.tsx | 5 + app/(auth)/register.tsx | 5 + app/_layout.tsx | 173 ++++++ app/index.tsx | 11 + app/post/[postId].tsx | 5 + app/user/[userId].tsx | 5 + index.ts | 9 +- package-lock.json | 15 +- package.json | 5 +- .../AppDesktopShell.tsx} | 142 ++--- src/app-navigation/AppRouteStack.tsx | 8 + src/components/business/CommentItem.tsx | 54 +- src/components/business/QRCodeScanner.tsx | 11 +- src/components/business/UserProfileHeader.tsx | 8 +- src/components/common/AppBackButton.tsx | 54 ++ src/components/common/ImageGallery.tsx | 59 +- src/components/common/index.ts | 1 + .../navigation/hooks/useNavigationState.ts | 60 -- .../navigation/navigationService.ts | 110 ---- src/infrastructure/navigation/types.ts | 58 -- src/navigation/AuthNavigator.tsx | 25 - src/navigation/HomeNavigator.tsx | 49 -- src/navigation/MainNavigator.tsx | 101 ---- src/navigation/MessageNavigator.tsx | 60 -- .../MobileTabNavigatorWithDelay.tsx | 555 ------------------ src/navigation/ProfileNavigator.tsx | 101 ---- src/navigation/RootNavigator.tsx | 288 --------- src/navigation/ScheduleNavigator.tsx | 42 -- src/navigation/SimpleMobileTabNavigator.tsx | 420 ------------- src/navigation/TabNavigator.tsx | 155 ----- src/navigation/hrefs.ts | 153 +++++ src/navigation/index.ts | 29 +- src/navigation/paramUtils.ts | 11 + src/navigation/types.ts | 135 ----- src/screens/auth/ForgotPasswordScreen.tsx | 12 +- src/screens/auth/LoginScreen.tsx | 23 +- src/screens/auth/QRCodeConfirmScreen.tsx | 32 +- src/screens/auth/RegisterScreen.tsx | 18 +- src/screens/create/CreatePostScreen.tsx | 39 +- src/screens/home/HomeScreen.tsx | 32 +- src/screens/home/PostDetailScreen.tsx | 158 ++--- src/screens/home/SearchScreen.tsx | 19 +- src/screens/message/ChatScreen.tsx | 41 +- src/screens/message/CreateGroupScreen.tsx | 10 +- src/screens/message/GroupInfoScreen.tsx | 33 +- .../message/GroupInviteDetailScreen.tsx | 53 +- src/screens/message/GroupMembersScreen.tsx | 17 +- .../message/GroupRequestDetailScreen.tsx | 53 +- src/screens/message/JoinGroupScreen.tsx | 18 +- src/screens/message/MessageListScreen.tsx | 61 +- src/screens/message/NotificationsScreen.tsx | 27 +- src/screens/message/PrivateChatInfoScreen.tsx | 30 +- .../components/ChatScreen/ChatHeader.tsx | 16 +- .../components/ChatScreen/MessageBubble.tsx | 24 +- .../components/ChatScreen/SegmentRenderer.tsx | 58 +- .../components/ChatScreen/bubbleStyles.ts | 4 +- .../message/components/ChatScreen/styles.ts | 36 +- .../components/ChatScreen/useChatScreen.ts | 99 ++-- .../message/components/EmbeddedChat.tsx | 38 +- src/screens/profile/BlockedUsersScreen.tsx | 6 +- src/screens/profile/FollowListScreen.tsx | 24 +- src/screens/profile/ProfileScreen.tsx | 9 +- src/screens/profile/SettingsScreen.tsx | 19 +- src/screens/profile/UserProfileScreen.tsx | 8 +- src/screens/profile/UserScreen.tsx | 9 +- src/screens/profile/useUserProfile.ts | 85 +-- src/screens/schedule/CourseDetailScreen.tsx | 35 +- src/screens/schedule/ScheduleScreen.tsx | 20 +- src/services/backgroundService.ts | 133 +---- src/services/groupService.ts | 169 ++++-- src/services/messageVibrationService.ts | 88 +++ src/services/sseService.ts | 30 +- src/stores/groupManager.ts | 8 +- src/stores/groupMemberListSources.ts | 10 +- src/stores/routePayloadCache.ts | 33 ++ src/types/dto.ts | 7 +- 111 files changed, 1829 insertions(+), 3214 deletions(-) create mode 100644 app/(app)/(tabs)/_layout.tsx create mode 100644 app/(app)/(tabs)/home/_layout.tsx create mode 100644 app/(app)/(tabs)/home/index.tsx create mode 100644 app/(app)/(tabs)/home/search.tsx create mode 100644 app/(app)/(tabs)/messages/_layout.tsx create mode 100644 app/(app)/(tabs)/messages/index.tsx create mode 100644 app/(app)/(tabs)/messages/notifications.tsx create mode 100644 app/(app)/(tabs)/profile/_layout.tsx create mode 100644 app/(app)/(tabs)/profile/account-security.tsx create mode 100644 app/(app)/(tabs)/profile/blocked-users.tsx create mode 100644 app/(app)/(tabs)/profile/bookmarks.tsx create mode 100644 app/(app)/(tabs)/profile/edit-profile.tsx create mode 100644 app/(app)/(tabs)/profile/index.tsx create mode 100644 app/(app)/(tabs)/profile/my-posts.tsx create mode 100644 app/(app)/(tabs)/profile/notification-settings.tsx create mode 100644 app/(app)/(tabs)/profile/settings.tsx create mode 100644 app/(app)/(tabs)/schedule/_layout.tsx create mode 100644 app/(app)/(tabs)/schedule/course.tsx create mode 100644 app/(app)/(tabs)/schedule/index.tsx create mode 100644 app/(app)/_layout.tsx create mode 100644 app/(app)/_layout.web.tsx create mode 100644 app/(app)/chat/[conversationId].tsx create mode 100644 app/(app)/chat/private-info.tsx create mode 100644 app/(app)/group/[groupId]/index.tsx create mode 100644 app/(app)/group/[groupId]/members.tsx create mode 100644 app/(app)/group/create.tsx create mode 100644 app/(app)/group/invite.tsx create mode 100644 app/(app)/group/join.tsx create mode 100644 app/(app)/group/request.tsx create mode 100644 app/(app)/posts/create.tsx create mode 100644 app/(app)/qrcode/login/[sessionId].tsx create mode 100644 app/(app)/users/[userId]/[type].tsx create mode 100644 app/(auth)/_layout.tsx create mode 100644 app/(auth)/forgot-password.tsx create mode 100644 app/(auth)/login.tsx create mode 100644 app/(auth)/register.tsx create mode 100644 app/_layout.tsx create mode 100644 app/index.tsx create mode 100644 app/post/[postId].tsx create mode 100644 app/user/[userId].tsx rename src/{navigation/DesktopNavigator.tsx => app-navigation/AppDesktopShell.tsx} (57%) create mode 100644 src/app-navigation/AppRouteStack.tsx create mode 100644 src/components/common/AppBackButton.tsx delete mode 100644 src/infrastructure/navigation/hooks/useNavigationState.ts delete mode 100644 src/infrastructure/navigation/navigationService.ts delete mode 100644 src/infrastructure/navigation/types.ts delete mode 100644 src/navigation/AuthNavigator.tsx delete mode 100644 src/navigation/HomeNavigator.tsx delete mode 100644 src/navigation/MainNavigator.tsx delete mode 100644 src/navigation/MessageNavigator.tsx delete mode 100644 src/navigation/MobileTabNavigatorWithDelay.tsx delete mode 100644 src/navigation/ProfileNavigator.tsx delete mode 100644 src/navigation/RootNavigator.tsx delete mode 100644 src/navigation/ScheduleNavigator.tsx delete mode 100644 src/navigation/SimpleMobileTabNavigator.tsx delete mode 100644 src/navigation/TabNavigator.tsx create mode 100644 src/navigation/hrefs.ts create mode 100644 src/navigation/paramUtils.ts delete mode 100644 src/navigation/types.ts create mode 100644 src/services/messageVibrationService.ts create mode 100644 src/stores/routePayloadCache.ts diff --git a/App.tsx b/App.tsx index 771c3c4..d511c1d 100644 --- a/App.tsx +++ b/App.tsx @@ -1,137 +1,7 @@ /** - * 萝卜BBS - 主应用入口 - * 配置导航、主题、状态管理等 + * 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。 + * 全局 Provider 与根布局见 app/_layout.tsx。 */ - -import React, { useEffect, useRef } from 'react'; -import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native'; -import { StatusBar } from 'expo-status-bar'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { PaperProvider } from 'react-native-paper'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import * as Notifications from 'expo-notifications'; - -// 配置通知处理器 - 必须在应用启动时设置 -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, // 即使在后台也要显示通知 - shouldPlaySound: true, // 播放声音 - shouldSetBadge: true, // 设置角标 - shouldShowBanner: true, // 显示横幅 - shouldShowList: true, // 显示通知列表 - }), -}); - -import MainNavigator from './src/navigation/MainNavigator'; -import { paperTheme } from './src/theme'; -import AppPromptBar from './src/components/common/AppPromptBar'; -import AppDialogHost from './src/components/common/AppDialogHost'; -import { installAlertOverride } from './src/services/alertOverride'; -// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库 - -// 创建 QueryClient 实例 -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 2, - staleTime: 5 * 60 * 1000, // 5分钟 - }, - }, -}); - -installAlertOverride(); - -// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline -if (Platform.OS === 'web') { - const style = document.createElement('style'); - style.textContent = ` - /* 移除TextInput focus时的浏览器默认outline */ - input:focus, textarea:focus, select:focus { - outline: none !important; - outline-width: 0 !important; - box-shadow: none !important; - } - - /* 移除focus-visible时的默认outline */ - input:focus-visible, textarea:focus-visible { - outline: none !important; - } - - /* 移除所有:focus相关的默认样式 */ - *:focus { - outline: none !important; - } - - *:focus-visible { - outline: none !important; - } - `; - document.head.appendChild(style); -} - export default function App() { - const appState = useRef(AppState.currentState); - const notificationResponseListener = useRef(null); - - // 系统通知功能初始化 - useEffect(() => { - const initNotifications = async () => { - const { systemNotificationService } = await import('./src/services/systemNotificationService'); - await systemNotificationService.initialize(); - - // 初始化后台保活服务 - const { initBackgroundService } = await import('./src/services/backgroundService'); - await initBackgroundService(); - - // 监听 App 状态变化 - const subscription = AppState.addEventListener('change', (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === 'active' - ) { - systemNotificationService.clearBadge(); - } - appState.current = nextAppState; - }); - - // 监听通知点击响应 - notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( - (response) => { - const data = response.notification.request.content.data; - void data; - } - ); - - // 监听通知到达(用于前台显示时额外处理) - const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( - (notification) => { - void notification; - } - ); - - return () => { - subscription.remove(); - notificationResponseListener.current?.remove(); - notificationReceivedSubscription.remove(); - }; - }; - - initNotifications(); - }, []); - - return ( - - - - - - - - - - - - - ); + return null; } diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..afbc258 --- /dev/null +++ b/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,96 @@ +import { Platform, useWindowDimensions } from 'react-native'; +import { Tabs } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { colors, shadows } from '../../../src/theme'; +import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; +import { useTotalUnreadCount } from '../../../src/stores'; + +const TAB_BAR_HEIGHT = 64; +const TAB_BAR_MARGIN = 14; +const TAB_BAR_FLOATING_MARGIN = 12; + +export default function TabsLayout() { + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const unreadCount = useTotalUnreadCount(); + const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; + + return ( + + ( + + ), + }} + /> + 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined, + tabBarIcon: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(app)/(tabs)/home/_layout.tsx b/app/(app)/(tabs)/home/_layout.tsx new file mode 100644 index 0000000..e30c650 --- /dev/null +++ b/app/(app)/(tabs)/home/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function HomeStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/home/index.tsx b/app/(app)/(tabs)/home/index.tsx new file mode 100644 index 0000000..3b9f7d4 --- /dev/null +++ b/app/(app)/(tabs)/home/index.tsx @@ -0,0 +1,5 @@ +import { HomeScreen } from '../../../../src/screens/home'; + +export default function HomeRoute() { + return ; +} diff --git a/app/(app)/(tabs)/home/search.tsx b/app/(app)/(tabs)/home/search.tsx new file mode 100644 index 0000000..defc617 --- /dev/null +++ b/app/(app)/(tabs)/home/search.tsx @@ -0,0 +1,5 @@ +import { SearchScreen } from '../../../../src/screens/home'; + +export default function HomeSearchRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/_layout.tsx b/app/(app)/(tabs)/messages/_layout.tsx new file mode 100644 index 0000000..9e34be5 --- /dev/null +++ b/app/(app)/(tabs)/messages/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function MessagesStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/messages/index.tsx b/app/(app)/(tabs)/messages/index.tsx new file mode 100644 index 0000000..032f6c9 --- /dev/null +++ b/app/(app)/(tabs)/messages/index.tsx @@ -0,0 +1,5 @@ +import { MessageListScreen } from '../../../../src/screens/message'; + +export default function MessagesRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/notifications.tsx b/app/(app)/(tabs)/messages/notifications.tsx new file mode 100644 index 0000000..6a60ecc --- /dev/null +++ b/app/(app)/(tabs)/messages/notifications.tsx @@ -0,0 +1,5 @@ +import { NotificationsScreen } from '../../../../src/screens/message'; + +export default function NotificationsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/_layout.tsx b/app/(app)/(tabs)/profile/_layout.tsx new file mode 100644 index 0000000..b87d4a1 --- /dev/null +++ b/app/(app)/(tabs)/profile/_layout.tsx @@ -0,0 +1,36 @@ +import { Stack } from 'expo-router'; +import { useRouter } from 'expo-router'; + +import { AppBackButton } from '../../../../src/components/common'; +import { colors } from '../../../../src/theme'; + +const headerOptions = { + headerStyle: { backgroundColor: colors.background.paper }, + headerTintColor: colors.text.primary, + headerTitleStyle: { fontWeight: '600' as const }, + headerShadowVisible: false, + headerBackTitle: '', +}; + +export default function ProfileStackLayout() { + const router = useRouter(); + + return ( + router.back()} />, + }} + > + + + + + + + + + + ); +} diff --git a/app/(app)/(tabs)/profile/account-security.tsx b/app/(app)/(tabs)/profile/account-security.tsx new file mode 100644 index 0000000..2eac261 --- /dev/null +++ b/app/(app)/(tabs)/profile/account-security.tsx @@ -0,0 +1,5 @@ +import { AccountSecurityScreen } from '../../../../src/screens/profile'; + +export default function AccountSecurityRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/blocked-users.tsx b/app/(app)/(tabs)/profile/blocked-users.tsx new file mode 100644 index 0000000..fb23915 --- /dev/null +++ b/app/(app)/(tabs)/profile/blocked-users.tsx @@ -0,0 +1,5 @@ +import { BlockedUsersScreen } from '../../../../src/screens/profile'; + +export default function BlockedUsersRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/bookmarks.tsx b/app/(app)/(tabs)/profile/bookmarks.tsx new file mode 100644 index 0000000..5fa89d9 --- /dev/null +++ b/app/(app)/(tabs)/profile/bookmarks.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function BookmarksRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/edit-profile.tsx b/app/(app)/(tabs)/profile/edit-profile.tsx new file mode 100644 index 0000000..b440978 --- /dev/null +++ b/app/(app)/(tabs)/profile/edit-profile.tsx @@ -0,0 +1,5 @@ +import { EditProfileScreen } from '../../../../src/screens/profile'; + +export default function EditProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/index.tsx b/app/(app)/(tabs)/profile/index.tsx new file mode 100644 index 0000000..7944f71 --- /dev/null +++ b/app/(app)/(tabs)/profile/index.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function ProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/my-posts.tsx b/app/(app)/(tabs)/profile/my-posts.tsx new file mode 100644 index 0000000..eeb11f9 --- /dev/null +++ b/app/(app)/(tabs)/profile/my-posts.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function MyPostsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/notification-settings.tsx b/app/(app)/(tabs)/profile/notification-settings.tsx new file mode 100644 index 0000000..0907e61 --- /dev/null +++ b/app/(app)/(tabs)/profile/notification-settings.tsx @@ -0,0 +1,5 @@ +import { NotificationSettingsScreen } from '../../../../src/screens/profile'; + +export default function NotificationSettingsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/settings.tsx b/app/(app)/(tabs)/profile/settings.tsx new file mode 100644 index 0000000..ec2060d --- /dev/null +++ b/app/(app)/(tabs)/profile/settings.tsx @@ -0,0 +1,5 @@ +import { SettingsScreen } from '../../../../src/screens/profile'; + +export default function SettingsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/schedule/_layout.tsx b/app/(app)/(tabs)/schedule/_layout.tsx new file mode 100644 index 0000000..66385c9 --- /dev/null +++ b/app/(app)/(tabs)/schedule/_layout.tsx @@ -0,0 +1,17 @@ +import { Stack } from 'expo-router'; + +export default function ScheduleStackLayout() { + return ( + + + + + ); +} diff --git a/app/(app)/(tabs)/schedule/course.tsx b/app/(app)/(tabs)/schedule/course.tsx new file mode 100644 index 0000000..a03e316 --- /dev/null +++ b/app/(app)/(tabs)/schedule/course.tsx @@ -0,0 +1,5 @@ +import { CourseDetailScreen } from '../../../../src/screens/schedule'; + +export default function CourseDetailRoute() { + return ; +} diff --git a/app/(app)/(tabs)/schedule/index.tsx b/app/(app)/(tabs)/schedule/index.tsx new file mode 100644 index 0000000..120f50f --- /dev/null +++ b/app/(app)/(tabs)/schedule/index.tsx @@ -0,0 +1,5 @@ +import { ScheduleScreen } from '../../../../src/screens/schedule'; + +export default function ScheduleRoute() { + return ; +} diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx new file mode 100644 index 0000000..00991db --- /dev/null +++ b/app/(app)/_layout.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { Redirect } from 'expo-router'; + +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayout() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/app/(app)/_layout.web.tsx b/app/(app)/_layout.web.tsx new file mode 100644 index 0000000..a8e5521 --- /dev/null +++ b/app/(app)/_layout.web.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { Redirect } from 'expo-router'; + +import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { BREAKPOINTS } from '../../src/hooks/useResponsive'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayoutWeb() { + const { width } = useWindowDimensions(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const useDesktopShell = width >= BREAKPOINTS.desktop; + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + if (useDesktopShell) { + return ; + } + + return ; +} diff --git a/app/(app)/chat/[conversationId].tsx b/app/(app)/chat/[conversationId].tsx new file mode 100644 index 0000000..f0bee9c --- /dev/null +++ b/app/(app)/chat/[conversationId].tsx @@ -0,0 +1,5 @@ +import { ChatScreen } from '../../../src/screens/message'; + +export default function ChatRoute() { + return ; +} diff --git a/app/(app)/chat/private-info.tsx b/app/(app)/chat/private-info.tsx new file mode 100644 index 0000000..e080eea --- /dev/null +++ b/app/(app)/chat/private-info.tsx @@ -0,0 +1,5 @@ +import { PrivateChatInfoScreen } from '../../../src/screens/message'; + +export default function PrivateChatInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/index.tsx b/app/(app)/group/[groupId]/index.tsx new file mode 100644 index 0000000..4a54deb --- /dev/null +++ b/app/(app)/group/[groupId]/index.tsx @@ -0,0 +1,5 @@ +import { GroupInfoScreen } from '../../../../src/screens/message'; + +export default function GroupInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/members.tsx b/app/(app)/group/[groupId]/members.tsx new file mode 100644 index 0000000..f0b2876 --- /dev/null +++ b/app/(app)/group/[groupId]/members.tsx @@ -0,0 +1,5 @@ +import { GroupMembersScreen } from '../../../../src/screens/message'; + +export default function GroupMembersRoute() { + return ; +} diff --git a/app/(app)/group/create.tsx b/app/(app)/group/create.tsx new file mode 100644 index 0000000..2dc7954 --- /dev/null +++ b/app/(app)/group/create.tsx @@ -0,0 +1,5 @@ +import { CreateGroupScreen } from '../../../src/screens/message'; + +export default function CreateGroupRoute() { + return ; +} diff --git a/app/(app)/group/invite.tsx b/app/(app)/group/invite.tsx new file mode 100644 index 0000000..462a0f6 --- /dev/null +++ b/app/(app)/group/invite.tsx @@ -0,0 +1,5 @@ +import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen'; + +export default function GroupInviteRoute() { + return ; +} diff --git a/app/(app)/group/join.tsx b/app/(app)/group/join.tsx new file mode 100644 index 0000000..7758296 --- /dev/null +++ b/app/(app)/group/join.tsx @@ -0,0 +1,5 @@ +import { JoinGroupScreen } from '../../../src/screens/message'; + +export default function JoinGroupRoute() { + return ; +} diff --git a/app/(app)/group/request.tsx b/app/(app)/group/request.tsx new file mode 100644 index 0000000..67b463d --- /dev/null +++ b/app/(app)/group/request.tsx @@ -0,0 +1,5 @@ +import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen'; + +export default function GroupRequestRoute() { + return ; +} diff --git a/app/(app)/posts/create.tsx b/app/(app)/posts/create.tsx new file mode 100644 index 0000000..1f11170 --- /dev/null +++ b/app/(app)/posts/create.tsx @@ -0,0 +1,5 @@ +import { CreatePostScreen } from '../../../src/screens/create'; + +export default function CreatePostRoute() { + return ; +} diff --git a/app/(app)/qrcode/login/[sessionId].tsx b/app/(app)/qrcode/login/[sessionId].tsx new file mode 100644 index 0000000..2614da6 --- /dev/null +++ b/app/(app)/qrcode/login/[sessionId].tsx @@ -0,0 +1,5 @@ +import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen'; + +export default function QrLoginConfirmRoute() { + return ; +} diff --git a/app/(app)/users/[userId]/[type].tsx b/app/(app)/users/[userId]/[type].tsx new file mode 100644 index 0000000..fd1ab1a --- /dev/null +++ b/app/(app)/users/[userId]/[type].tsx @@ -0,0 +1,5 @@ +import FollowListScreen from '../../../../src/screens/profile/FollowListScreen'; + +export default function FollowListRoute() { + return ; +} diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/app/(auth)/forgot-password.tsx b/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..a819c45 --- /dev/null +++ b/app/(auth)/forgot-password.tsx @@ -0,0 +1,5 @@ +import { ForgotPasswordScreen } from '../../src/screens/auth'; + +export default function ForgotPasswordRoute() { + return ; +} diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx new file mode 100644 index 0000000..91b576f --- /dev/null +++ b/app/(auth)/login.tsx @@ -0,0 +1,5 @@ +import { LoginScreen } from '../../src/screens/auth'; + +export default function LoginRoute() { + return ; +} diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx new file mode 100644 index 0000000..fb04e28 --- /dev/null +++ b/app/(auth)/register.tsx @@ -0,0 +1,5 @@ +import { RegisterScreen } from '../../src/screens/auth'; + +export default function RegisterRoute() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..1cdf676 --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AppState, AppStateStatus, Platform, View, ActivityIndicator, StyleSheet } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { PaperProvider } from 'react-native-paper'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; + +import { paperTheme } from '../src/theme'; +import { AppBackButton } from '../src/components/common'; +import AppPromptBar from '../src/components/common/AppPromptBar'; +import AppDialogHost from '../src/components/common/AppDialogHost'; +import { installAlertOverride } from '../src/services/alertOverride'; +import { useAuthStore } from '../src/stores'; +import { colors } from '../src/theme'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 5 * 60 * 1000, + }, + }, +}); + +installAlertOverride(); + +if (Platform.OS === 'web' && typeof document !== 'undefined') { + const style = document.createElement('style'); + style.textContent = ` + input:focus, textarea:focus, select:focus { + outline: none !important; + outline-width: 0 !important; + box-shadow: none !important; + } + input:focus-visible, textarea:focus-visible { + outline: none !important; + } + *:focus { outline: none !important; } + *:focus-visible { outline: none !important; } + `; + document.head.appendChild(style); +} + +function SessionGate({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser); + + useEffect(() => { + fetchCurrentUser().finally(() => setReady(true)); + }, [fetchCurrentUser]); + + if (!ready) { + return ( + + + + ); + } + return <>{children}; +} + +function NotificationBootstrap() { + const appState = useRef(AppState.currentState); + const notificationResponseListener = useRef(null); + + useEffect(() => { + const initNotifications = async () => { + const { systemNotificationService } = await import('../src/services/systemNotificationService'); + await systemNotificationService.initialize(); + const { initBackgroundService } = await import('../src/services/backgroundService'); + await initBackgroundService(); + + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + systemNotificationService.clearBadge(); + } + appState.current = nextAppState; + }); + + notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( + (response) => { + void response.notification.request.content.data; + } + ); + + const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( + (notification) => { + void notification; + } + ); + + return () => { + subscription.remove(); + notificationResponseListener.current?.remove(); + notificationReceivedSubscription.remove(); + }; + }; + + initNotifications(); + }, []); + + return null; +} + +export default function RootLayout() { + const router = useRouter(); + + return ( + + + + + + + + + + + + router.back()} />, + }} + /> + router.back()} />, + }} + /> + + + + + + + + + ); +} + +const gateStyles = StyleSheet.create({ + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..f412813 --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,11 @@ +import { Redirect } from 'expo-router'; + +import { useAuthStore } from '../src/stores'; + +export default function Index() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (isAuthenticated) { + return ; + } + return ; +} diff --git a/app/post/[postId].tsx b/app/post/[postId].tsx new file mode 100644 index 0000000..4ac907f --- /dev/null +++ b/app/post/[postId].tsx @@ -0,0 +1,5 @@ +import { PostDetailScreen } from '../../src/screens/home'; + +export default function PostDetailRoute() { + return ; +} diff --git a/app/user/[userId].tsx b/app/user/[userId].tsx new file mode 100644 index 0000000..12313c2 --- /dev/null +++ b/app/user/[userId].tsx @@ -0,0 +1,5 @@ +import { UserScreen } from '../../src/screens/profile'; + +export default function UserProfileRoute() { + return ; +} diff --git a/index.ts b/index.ts index 1d6e981..5b83418 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1 @@ -import { registerRootComponent } from 'expo'; - -import App from './App'; - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); +import 'expo-router/entry'; diff --git a/package-lock.json b/package-lock.json index 507e99e..076cc9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", @@ -5542,6 +5543,18 @@ "expo": "*" } }, + "node_modules/expo-background-task": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz", + "integrity": "sha512-mkwdS8T34P0pSphr08yv9lsQ6pd1pMgFdVq0wd3Ane3h5heZj/il6mes/b8OQDoxtD3mLFj7yKDYOU9dAwSrJA==", + "license": "MIT", + "dependencies": { + "expo-task-manager": "~55.0.10" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-camera": { "version": "55.0.10", "resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz", diff --git a/package.json b/package.json index 144167c..cd26ced 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "carrot_bbs", "version": "1.0.1", - "main": "index.ts", + "main": "expo-router/entry", "scripts": { "start": "expo start", "android": "expo run:android", @@ -25,7 +25,8 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", diff --git a/src/navigation/DesktopNavigator.tsx b/src/app-navigation/AppDesktopShell.tsx similarity index 57% rename from src/navigation/DesktopNavigator.tsx rename to src/app-navigation/AppDesktopShell.tsx index e58db7c..a807f98 100644 --- a/src/navigation/DesktopNavigator.tsx +++ b/src/app-navigation/AppDesktopShell.tsx @@ -1,107 +1,91 @@ -/** - * 桌面端导航器 - * 为平板/桌面设备提供侧边栏导航体验 - */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { View, StyleSheet, ScrollView, TouchableOpacity, Text, - Animated, Platform, } from 'react-native'; import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { usePathname, useRouter } from 'expo-router'; -import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'; -import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types'; import { colors, shadows } from '../theme'; -import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState'; +import { useTotalUnreadCount } from '../stores'; +import { AppRouteStack } from './AppRouteStack'; -// 导入屏幕 -import { HomeScreen } from '../screens/home'; -import { ScheduleScreen } from '../screens/schedule'; -import { MessageListScreen } from '../screens/message'; -import { ProfileScreen } from '../screens/profile'; +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; -// 侧边栏常量 -const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS; +const SIDEBAR_WIDTH_DESKTOP = 240; +const SIDEBAR_WIDTH_TABLET = 200; +const SIDEBAR_COLLAPSED_WIDTH = 72; -// 导航项配置 -const NAV_ITEMS: NavItemConfig[] = [ - { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, - { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' }, - { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, - { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, +const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [ + { name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' }, + { name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' }, + { name: 'ScheduleTab', label: '课表', href: '/schedule', icon: 'calendar-today', iconOutline: 'calendar-today' }, + { name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' }, ]; -interface DesktopNavigatorProps { - unreadCount?: number; +function pathToTab(pathname: string): TabName { + if (pathname.startsWith('/messages')) return 'MessageTab'; + if (pathname.startsWith('/schedule')) return 'ScheduleTab'; + if (pathname.startsWith('/profile')) return 'ProfileTab'; + if (pathname.startsWith('/home')) return 'HomeTab'; + return 'HomeTab'; } -export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { +export function AppDesktopShell() { const insets = useSafeAreaInsets(); - const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState(); + const router = useRouter(); + const pathname = usePathname(); + const unreadCount = useTotalUnreadCount(); + const [isCollapsed, setIsCollapsed] = useState(false); const [isDesktop, setIsDesktop] = useState(false); - // 检测是否是桌面尺寸 useEffect(() => { - const checkDesktop = () => { - const width = window.innerWidth || document.documentElement.clientWidth; - setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP); + if (Platform.OS !== 'web') return; + const check = () => { + const w = window.innerWidth || document.documentElement.clientWidth; + setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP); }; - - checkDesktop(); - window.addEventListener('resize', checkDesktop); - setIsReady(true); - - return () => window.removeEventListener('resize', checkDesktop); - }, [setIsReady]); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); - // 计算侧边栏宽度 - const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET); + const sidebarWidth = isCollapsed + ? SIDEBAR_COLLAPSED_WIDTH + : isDesktop + ? SIDEBAR_WIDTH_DESKTOP + : SIDEBAR_WIDTH_TABLET; - // 处理 Tab 切换 - const handleTabChange = useCallback((tab: TabName) => { - setCurrentTab(tab); - }, [setCurrentTab]); + const currentTab = pathToTab(pathname || '/home'); - // 渲染当前 Tab 的内容 - const renderTabContent = () => { - switch (currentTab) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; + const handleTabChange = useCallback( + (href: string) => { + router.replace(href); + }, + [router] + ); return ( - {/* 侧边栏 */} - - {/* Logo 区域 */} + - {!isCollapsed && ( - 胡萝卜BBS - )} + {!isCollapsed && 胡萝卜BBS} - - {/* 导航项 */} {NAV_ITEMS.map((item) => { const isActive = currentTab === item.name; const showBadge = item.name === 'MessageTab' && unreadCount > 0; - return ( handleTabChange(item.name)} + onPress={() => handleTabChange(item.href)} activeOpacity={0.7} > - {showBadge && ( + {showBadge ? ( {unreadCount > 99 ? '99+' : unreadCount} - )} + ) : null} - {!isCollapsed && ( - - {item.label} - - )} + {!isCollapsed ? ( + {item.label} + ) : null} ); })} - - {/* 折叠按钮 */} setIsCollapsed((c) => !c)} activeOpacity={0.7} > - - {/* 主内容区域 */} - {renderTabContent()} + ); diff --git a/src/app-navigation/AppRouteStack.tsx b/src/app-navigation/AppRouteStack.tsx new file mode 100644 index 0000000..5ea6fed --- /dev/null +++ b/src/app-navigation/AppRouteStack.tsx @@ -0,0 +1,8 @@ +import { Stack } from 'expo-router'; + +/** + * 已登录区内栈:由 app/(app) 下文件系统自动注册子路由 + */ +export function AppRouteStack() { + return ; +} diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index a0c5b0d..c42ac2b 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -329,18 +329,18 @@ const CommentItem: React.FC = ({ {/* 子评论操作按钮 */} onReplyPress?.(reply)} > - + 回复 {/* 删除按钮 - 子评论作者可见 */} {isSubReplyAuthor && ( handleSubReplyDelete(reply)} disabled={isDeleting} > @@ -349,7 +349,7 @@ const CommentItem: React.FC = ({ size={12} color={colors.text.hint} /> - + {isDeleting ? '删除中' : '删除'} @@ -476,21 +476,26 @@ const CommentItem: React.FC = ({ const styles = StyleSheet.create({ container: { flexDirection: 'row', - paddingVertical: spacing.sm, + paddingTop: spacing.md, + paddingBottom: spacing.xs, paddingHorizontal: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, + backgroundColor: 'transparent', }, content: { flex: 1, marginLeft: spacing.sm, + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', - marginBottom: spacing.xs, + marginBottom: spacing.sm, }, userInfo: { flexDirection: 'row', @@ -537,7 +542,9 @@ const styles = StyleSheet.create({ backgroundColor: colors.background.default, paddingHorizontal: spacing.xs, paddingVertical: 2, - borderRadius: borderRadius.sm, + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, floorText: { fontSize: fontSizes.xs, @@ -546,7 +553,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs, }, commentContent: { - marginBottom: spacing.xs, + marginBottom: spacing.sm, }, text: { lineHeight: 20, @@ -559,17 +566,24 @@ const styles = StyleSheet.create({ actionButton: { flexDirection: 'row', alignItems: 'center', - marginRight: spacing.md, - paddingVertical: spacing.xs, + marginRight: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, actionText: { - marginLeft: 2, + marginLeft: 4, fontSize: fontSizes.xs, }, subRepliesContainer: { marginTop: spacing.sm, backgroundColor: colors.background.default, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, padding: spacing.sm, }, subReplyItem: { @@ -607,6 +621,16 @@ const styles = StyleSheet.create({ alignItems: 'center', marginTop: spacing.xs, }, + subActionButton: { + flexDirection: 'row', + alignItems: 'center', + marginRight: spacing.sm, + paddingVertical: 2, + }, + subActionText: { + marginLeft: 2, + fontSize: fontSizes.xs, + }, }); export default CommentItem; diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx index 5a007e7..a83f370 100644 --- a/src/components/business/QRCodeScanner.tsx +++ b/src/components/business/QRCodeScanner.tsx @@ -9,23 +9,20 @@ import { Dimensions, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; const { width, height } = Dimensions.get('window'); -type NavigationProp = NativeStackNavigationProp; - interface QRCodeScannerProps { visible: boolean; onClose: () => void; } export const QRCodeScanner: React.FC = ({ visible, onClose }) => { - const navigation = useNavigation(); + const router = useRouter(); const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); @@ -52,7 +49,7 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } const sessionId = extractSessionId(data); if (sessionId) { // 跳转到确认页面 - navigation.navigate('QRCodeConfirm', { sessionId }); + router.push(hrefs.hrefQrLoginConfirm(sessionId)); } else { Alert.alert('无效的二维码', '无法识别该二维码'); } diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index d680ae0..80d4e20 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -236,22 +236,22 @@ const UserProfileHeader: React.FC = ({ {/* 个人信息标签 */} - {user.location && ( + {user.location?.trim() ? ( {user.location} - )} - {user.website && ( + ) : null} + {user.website?.trim() ? ( {user.website.replace(/^https?:\/\//, '')} - )} + ) : null} diff --git a/src/components/common/AppBackButton.tsx b/src/components/common/AppBackButton.tsx new file mode 100644 index 0000000..2e40bc7 --- /dev/null +++ b/src/components/common/AppBackButton.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import { borderRadius, colors, spacing } from '../../theme'; + +type AppBackButtonIcon = 'arrow-left' | 'close'; + +interface AppBackButtonProps { + onPress: () => void; + icon?: AppBackButtonIcon; + style?: StyleProp; + iconColor?: string; + backgroundColor?: string; + hitSlop?: number; + testID?: string; +} + +const AppBackButton: React.FC = ({ + onPress, + icon = 'arrow-left', + style, + iconColor = colors.text.primary, + backgroundColor = colors.background.paper, + hitSlop = 8, + testID, +}) => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: 36, + height: 36, + borderRadius: borderRadius.full, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs, + }, +}); + +export default AppBackButton; diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index ad71b5c..53f6451 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -87,6 +87,7 @@ export const ImageGallery: React.FC = ({ const closeTimerRef = useRef | null>(null); const isClosingRef = useRef(false); const currentIndexRef = useRef(initialIndex); + const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null); const [currentIndex, setCurrentIndex] = useState(initialIndex); currentIndexRef.current = currentIndex; const [showControls, setShowControls] = useState(true); @@ -173,6 +174,14 @@ export const ImageGallery: React.FC = ({ [validImages.length, onIndexChange] ); + const updateIndexFromSwipe = useCallback( + (newIndex: number, direction: -1 | 1) => { + pendingSwipeDirectionRef.current = direction; + updateIndex(newIndex); + }, + [updateIndex] + ); + const toggleControls = useCallback(() => { setShowControls(prev => !prev); }, []); @@ -189,18 +198,6 @@ export const ImageGallery: React.FC = ({ }, 120); }, [onClose]); - const goToPrev = useCallback(() => { - if (currentIndex > 0) { - updateIndex(currentIndex - 1); - } - }, [currentIndex, updateIndex]); - - const goToNext = useCallback(() => { - if (currentIndex < validImages.length - 1) { - updateIndex(currentIndex + 1); - } - }, [currentIndex, validImages.length, updateIndex]); - // 显示短暂提示 const showToast = useCallback((type: 'success' | 'error') => { setSaveToast(type); @@ -269,6 +266,38 @@ export const ImageGallery: React.FC = ({ // 滑动切换图片相关状态 const swipeTranslateX = useSharedValue(0); + // 滑动切图完成后,让新图从目标方向进入,避免旧图先弹回导致前后图闪烁 + useEffect(() => { + const direction = pendingSwipeDirectionRef.current; + if (direction == null) { + return; + } + pendingSwipeDirectionRef.current = null; + swipeTranslateX.value = -direction * SCREEN_WIDTH; + swipeTranslateX.value = withTiming(0, { duration: 180 }); + }, [currentIndex, swipeTranslateX]); + + const switchWithDirection = useCallback( + (targetIndex: number, direction: -1 | 1) => { + swipeTranslateX.value = withTiming(direction * SCREEN_WIDTH, { duration: 200 }, () => { + runOnJS(updateIndexFromSwipe)(targetIndex, direction); + }); + }, + [swipeTranslateX, updateIndexFromSwipe] + ); + + const goToPrev = useCallback(() => { + if (currentIndex > 0) { + switchWithDirection(currentIndex - 1, 1); + } + }, [currentIndex, switchWithDirection]); + + const goToNext = useCallback(() => { + if (currentIndex < validImages.length - 1) { + switchWithDirection(currentIndex + 1, -1); + } + }, [currentIndex, validImages.length, switchWithDirection]); + // 统一的滑动手势:放大时拖动,未放大时切换图片 const panGesture = Gesture.Pan() .activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突 @@ -309,14 +338,12 @@ export const ImageGallery: React.FC = ({ if (shouldGoNext && currentIndex < validImages.length - 1) { // 向左滑动,显示下一张 swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex + 1); - swipeTranslateX.value = 0; + runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1); }); } else if (shouldGoPrev && currentIndex > 0) { // 向右滑动,显示上一张 swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex - 1); - swipeTranslateX.value = 0; + runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1); }); } else { // 回弹到原位 diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 57bb5e1..6b737c4 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer'; export { default as ResponsiveGrid } from './ResponsiveGrid'; export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack'; export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout'; +export { default as AppBackButton } from './AppBackButton'; // 图片相关组件 export { default as SmartImage } from './SmartImage'; diff --git a/src/infrastructure/navigation/hooks/useNavigationState.ts b/src/infrastructure/navigation/hooks/useNavigationState.ts deleted file mode 100644 index 2792e37..0000000 --- a/src/infrastructure/navigation/hooks/useNavigationState.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 导航状态管理 Hook - * 管理导航相关状态,如当前路由、导航历史等 - */ - -import { useState, useCallback, useEffect } from 'react'; -import { useNavigationState as useRNNavigationState, Route } from '@react-navigation/native'; -import type { RootStackParamList } from '../../../navigation/types'; - -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -interface NavigationState { - currentTab: TabName; - isCollapsed: boolean; - isReady: boolean; -} - -interface NavigationActions { - setCurrentTab: (tab: TabName) => void; - toggleCollapse: () => void; - setIsReady: (ready: boolean) => void; -} - -/** - * 导航状态管理 Hook - */ -export function useNavigationState(): NavigationState & NavigationActions { - const [currentTab, setCurrentTab] = useState('HomeTab'); - const [isCollapsed, setIsCollapsed] = useState(false); - const [isReady, setIsReady] = useState(false); - - const toggleCollapse = useCallback(() => { - setIsCollapsed(prev => !prev); - }, []); - - return { - currentTab, - isCollapsed, - isReady, - setCurrentTab, - toggleCollapse, - setIsReady, - }; -} - -/** - * 获取当前路由名称 - */ -export function useCurrentRouteName(): string | null { - const routeName = useRNNavigationState(state => state?.routes[state.index]?.name ?? null); - return routeName; -} - -/** - * 检查是否在指定路由 - */ -export function useIsRoute(routeName: keyof RootStackParamList): boolean { - const currentRoute = useCurrentRouteName(); - return currentRoute === routeName; -} diff --git a/src/infrastructure/navigation/navigationService.ts b/src/infrastructure/navigation/navigationService.ts deleted file mode 100644 index 270f9cd..0000000 --- a/src/infrastructure/navigation/navigationService.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * 导航服务层 - * 提供全局导航功能,解耦组件与导航状态的直接依赖 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; - -class NavigationService { - private navigationRef: NavigationContainerRef | null = null; - private isReady: boolean = false; - - /** - * 设置导航引用 - */ - setNavigationRef(ref: NavigationContainerRef | null): void { - this.navigationRef = ref; - this.isReady = ref !== null; - } - - /** - * 检查导航是否就绪 - */ - isNavigationReady(): boolean { - return this.isReady && this.navigationRef !== null; - } - - /** - * 导航到指定路由 - */ - navigate(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.navigate(routeName as any, params); - } - - /** - * 返回上一页 - */ - goBack(): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.goBack(); - } - - /** - * 获取当前路由 - */ - getCurrentRoute(): Route | null { - if (!this.isNavigationReady()) { - return null; - } - return this.navigationRef!.getCurrentRoute() ?? null; - } - - /** - * 获取当前路由名称 - */ - getCurrentRouteName(): string | null { - const route = this.getCurrentRoute(); - return route?.name ?? null; - } - - /** - * 重置导航到指定路由 - */ - reset(routes: { name: string; params?: any }[], index: number = 0): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.reset({ - index, - routes: routes.map(r => ({ name: r.name, params: r.params })), - }); - } - - /** - * 替换当前路由 - */ - replace(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.dispatch({ - type: 'REPLACE', - payload: { name: routeName, params }, - }); - } - - /** - * 导航到根路由 - */ - navigateToRoot(): void { - this.reset([{ name: 'Main' }]); - } - - /** - * 导航到认证页面 - */ - navigateToAuth(): void { - this.reset([{ name: 'Auth' }]); - } -} - -export const navigationService = new NavigationService(); diff --git a/src/infrastructure/navigation/types.ts b/src/infrastructure/navigation/types.ts deleted file mode 100644 index 065edc6..0000000 --- a/src/infrastructure/navigation/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 导航基础设施类型定义 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; -import type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -} from '../../navigation/types'; - -// ==================== 导航服务类型 ==================== -export interface NavigationServiceInterface { - setNavigationRef(ref: NavigationContainerRef | null): void; - isNavigationReady(): boolean; - navigate(routeName: string, params?: any): void; - goBack(): void; - getCurrentRoute(): Route | null; - getCurrentRouteName(): string | null; - reset(routes: { name: string; params?: any }[], index?: number): void; - replace(routeName: string, params?: any): void; - navigateToRoot(): void; - navigateToAuth(): void; -} - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} - -// ==================== 导航配置常量 ==================== -export const NAVIGATION_CONSTANTS = { - SIDEBAR_WIDTH_DESKTOP: 240, - SIDEBAR_WIDTH_TABLET: 200, - SIDEBAR_COLLAPSED_WIDTH: 72, - MOBILE_TAB_FLOATING_MARGIN: 12, - LAST_ACTIVE_TAB_KEY: 'last_active_tab', -} as const; - -// ==================== 重新导出导航类型 ==================== -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -}; diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx deleted file mode 100644 index f1041be..0000000 --- a/src/navigation/AuthNavigator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 认证流程导航 - * 处理登录、注册、忘记密码等认证页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { AuthStackParamList } from './types'; - -import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; - -const AuthStack = createNativeStackNavigator(); - -export function AuthNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx deleted file mode 100644 index 2589651..0000000 --- a/src/navigation/HomeNavigator.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 首页 Stack 导航 - * 处理首页相关页面:首页、搜索等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { HomeStackParamList } from './types'; - -import { colors } from '../theme'; -import { HomeScreen, SearchScreen } from '../screens/home'; - -const HomeStack = createNativeStackNavigator(); - -export function HomeNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx deleted file mode 100644 index 9f864c7..0000000 --- a/src/navigation/MainNavigator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 主导航组件(重构版) - * - * 此文件仅保留导航器组装逻辑,所有业务逻辑已解耦至: - * - RootNavigator: 处理根导航和认证状态 - * - DesktopNavigator: 处理桌面端侧边栏导航 - * - SimpleMobileTabNavigator: 处理移动端 Tab 导航 - * - navigationService: 提供全局导航方法 - * - * 原文件 1118 行已精简至约 200 行 - */ - -import React, { useEffect, useState } from 'react'; -import { LinkingOptions } from '@react-navigation/native'; -import { Platform } from 'react-native'; - -import type { RootStackParamList } from './types'; -import { RootNavigator } from './RootNavigator'; - -// 导入认证 Store -import { useAuthStore } from '../stores'; - -// Deep linking 配置 -const linking: LinkingOptions = { - prefixes: ['carrotbbs://'], - config: { - screens: { - Auth: { - screens: { - Login: 'login', - Register: 'register', - ForgotPassword: 'forgot-password', - }, - }, - Main: { - screens: { - HomeTab: { - screens: { - Home: 'home', - Search: 'search', - }, - }, - MessageTab: { - screens: { - MessageList: 'messages', - Notifications: 'notifications', - }, - }, - ProfileTab: { - screens: { - Profile: 'me', - Settings: 'settings', - EditProfile: 'me/edit', - AccountSecurity: 'me/security', - NotificationSettings: 'me/notifications', - BlockedUsers: 'me/blocked', - }, - }, - }, - }, - PostDetail: 'posts/:postId', - UserProfile: 'users/:userId', - CreatePost: 'posts/create', - Chat: 'chat/:conversationId', - FollowList: 'users/:userId/:type', - QRCodeConfirm: 'qrcode/login/:sessionId', - }, - }, -}; - -/** - * 主导航组件 - * - * 职责: - * 1. 初始化认证状态 - * 2. 根据认证状态渲染 RootNavigator - * 3. 配置 Deep Linking - */ -export default function MainNavigator() { - const { isAuthenticated, fetchCurrentUser } = useAuthStore(); - const [isInitializing, setIsInitializing] = useState(true); - - // 初始化认证状态 - useEffect(() => { - const initAuth = async () => { - await fetchCurrentUser(); - setIsInitializing(false); - }; - initAuth(); - }, [fetchCurrentUser]); - - return ( - - ); -} - -// 导出 linking 配置供外部使用 -export { linking }; diff --git a/src/navigation/MessageNavigator.tsx b/src/navigation/MessageNavigator.tsx deleted file mode 100644 index cb343c5..0000000 --- a/src/navigation/MessageNavigator.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 消息 Stack 导航 - * 处理消息相关页面:消息列表、通知等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { MessageStackParamList } from './types'; - -import { colors } from '../theme'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const MessageStack = createNativeStackNavigator(); - -export function MessageNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/MobileTabNavigatorWithDelay.tsx b/src/navigation/MobileTabNavigatorWithDelay.tsx deleted file mode 100644 index 2417596..0000000 --- a/src/navigation/MobileTabNavigatorWithDelay.tsx +++ /dev/null @@ -1,555 +0,0 @@ -/** - * 移动端 Tab Navigator(完全独立版本) - * - * 这个组件完全独立于 MainNavigator.tsx 中的其他导航组件, - * 用于解决从大屏切换到小屏时的白屏问题。 - * - * 问题根源: - * - React Navigation 的 TabNavigator 在初始化时需要完整的 navigation state - * - 从大屏(Sidebar)切换到小屏(BottomTab)时,state 可能还没有准备好 - * - 导致 TabRouter 报错:"Cannot read properties of undefined (reading 'filter')" - * - * 解决方案: - * - 完全独立的组件,不依赖外部 navigation state - * - 使用延迟初始化,确保 React Navigation 内部状态就绪 - * - 错误边界和重试机制 - */ - -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { - View, - ActivityIndicator, - StyleSheet, - AppState, - Platform, -} from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - ChatScreen, - NotificationsScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - GroupInfoScreen, - GroupMembersScreen, - PrivateChatInfoScreen -} from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; -import { CreatePostScreen } from '../screens/create'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; - -// ==================== 类型定义 ==================== -export type MainTabParamList = { - HomeTab: undefined; - MessageTab: undefined; - ScheduleTab: undefined; - ProfileTab: undefined; -}; - -export type HomeStackParamList = { - Home: undefined; - Search: undefined; - CreatePost: undefined; -}; - -export type MessageStackParamList = { - MessageList: undefined; - Notifications: undefined; - PrivateChatInfo: { conversationId: string; userId: string }; -}; - -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { courseId: string }; -}; - -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== Stack Navigators ==================== -const HomeStack = createNativeStackNavigator(); -const MessageStack = createNativeStackNavigator(); -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); -const Tab = createBottomTabNavigator(); - -// ==================== 常量 ==================== -const MOBILE_TAB_FLOATING_MARGIN = 12; -const INITIALIZATION_DELAY = 500; // ms,增加延迟时间确保 React Navigation 完全初始化 -const MAX_RETRY_ATTEMPTS = 5; -const RETRY_DELAY = 500; // ms - -// ==================== Stack Navigator 组件 ==================== - -function HomeStackNavigatorComponent() { - return ( - - - - - - ); -} - -function MessageStackNavigatorComponent() { - return ( - - - - - - ); -} - -function ScheduleStackNavigatorComponent() { - return ( - - - - - ); -} - -function ProfileStackNavigatorComponent() { - return ( - - - - - - - - - - - ); -} - -// ==================== 主组件:MobileTabNavigatorWithDelay ==================== - -export function MobileTabNavigatorWithDelay() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [isReady, setIsReady] = useState(false); - const [hasError, setHasError] = useState(false); - const initAttemptRef = useRef(0); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - // 延迟初始化 - useEffect(() => { - let timeoutId: NodeJS.Timeout; - let isMounted = true; - - const initializeNavigator = async () => { - try { - initAttemptRef.current += 1; - console.log( - `[MobileTabNavigator] Initialization attempt ${initAttemptRef.current}/${MAX_RETRY_ATTEMPTS}` - ); - - // 延迟初始化 - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, INITIALIZATION_DELAY); - }); - - if (!isMounted) return; - - // 后台检测 - if (Platform.OS !== 'web' && AppState.currentState === 'background') { - console.log('[MobileTabNavigator] App in background, delaying...'); - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, 300); - }); - } - - if (!isMounted) return; - - setIsReady(true); - setHasError(false); - console.log('[MobileTabNavigator] Initialization successful'); - } catch (error) { - console.error('[MobileTabNavigator] Initialization error:', error); - - if (!isMounted) return; - - if (initAttemptRef.current < MAX_RETRY_ATTEMPTS) { - console.log('[MobileTabNavigator] Retrying...'); - timeoutId = setTimeout(initializeNavigator, RETRY_DELAY); - } else { - setHasError(true); - } - } - }; - - initializeNavigator(); - - return () => { - isMounted = false; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, []); - - // 渲染加载状态 - if (!isReady && !hasError) { - return ( - - - - ); - } - - // 渲染错误状态(带重试按钮) - if (hasError) { - return ( - - - - - ); - } - - // 渲染 Tab Navigator - return ( - - ( - - - - ), - }} - /> - 0 - ? messageUnreadCount > 99 - ? '99+' - : messageUnreadCount - : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, - errorContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - padding: 20, - }, - retryIndicator: { - marginTop: 16, - }, - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/ProfileNavigator.tsx b/src/navigation/ProfileNavigator.tsx deleted file mode 100644 index 0d57373..0000000 --- a/src/navigation/ProfileNavigator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 个人中心 Stack 导航 - * 处理个人中心相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ProfileStackParamList } from './types'; - -import { colors } from '../theme'; -import { - ProfileScreen, - SettingsScreen, - EditProfileScreen, - NotificationSettingsScreen, - BlockedUsersScreen, - AccountSecurityScreen, -} from '../screens/profile'; - -const ProfileStack = createNativeStackNavigator(); - -export function ProfileNavigator() { - return ( - - - - - - - - - - - ); -} diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx deleted file mode 100644 index 8ae0620..0000000 --- a/src/navigation/RootNavigator.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/** - * 根导航器 - * 处理整个应用的顶级导航,包括认证状态切换 - */ -import React, { useEffect, useState } from 'react'; -import { View, ActivityIndicator, StyleSheet } from 'react-native'; -import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; - -import type { RootStackParamList } from './types'; -import { colors } from '../theme'; -import { navigationService } from '../infrastructure/navigation/navigationService'; - -import { AuthNavigator } from './AuthNavigator'; -import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; -import { DesktopNavigator } from './DesktopNavigator'; - -import { useResponsive } from '../hooks'; -import { useTotalUnreadCount } from '../stores'; - -// 导入全局屏幕组件 -import { PostDetailScreen } from '../screens/home'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; -import { CreatePostScreen } from '../screens/create'; -import { QRCodeConfirmScreen } from '../screens/auth/QRCodeConfirmScreen'; -import { - ChatScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupInfoScreen, - GroupMembersScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const RootStack = createNativeStackNavigator(); - -interface RootNavigatorProps { - isAuthenticated: boolean; - isInitializing: boolean; -} - -// 未认证时可访问的屏幕 - 返回数组而不是 JSX -const getPublicScreens = () => [ - , - , -]; - -// 认证后可访问的屏幕 - 返回数组而不是 JSX -const getAuthenticatedScreens = () => [ - , - , - , - , - , - , - , - , - , - , - , - , - , -]; - -export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) { - const { isMobile } = useResponsive(); - const unreadCount = useTotalUnreadCount(); - - // 设置导航引用 - const setNavigationRef = (ref: any) => { - navigationService.setNavigationRef(ref); - }; - - // 加载中显示 - if (isInitializing) { - return ( - - - - ); - } - - return ( - - - {isAuthenticated ? ( - <> - - {() => - isMobile ? ( - - ) : ( - - ) - } - - {getAuthenticatedScreens()} - - ) : ( - <> - - {getPublicScreens()} - - )} - - - ); -} - -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, -}); diff --git a/src/navigation/ScheduleNavigator.tsx b/src/navigation/ScheduleNavigator.tsx deleted file mode 100644 index 30676aa..0000000 --- a/src/navigation/ScheduleNavigator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 课程表 Stack 导航 - * 处理课程表相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ScheduleStackParamList } from './types'; - -import { colors } from '../theme'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; - -const ScheduleStack = createNativeStackNavigator(); - -export function ScheduleNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx deleted file mode 100644 index f4720ad..0000000 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ /dev/null @@ -1,420 +0,0 @@ -/** - * 简单的移动端 Tab Navigator(自定义 Tab 栏) - * - * 策略(在 EnsureSingleNavigator 限制下尽量省加载): - * - 首页、消息:屏幕本身不带根级 NativeStack,首次进入后保留挂载,仅 display 隐藏 → 来回切换快。 - * - 课表、我的:各含一个 NativeStack,同一时刻只挂载当前选中的一个,避免重复注册 Navigator。 - * - * 其它可叠加的优化(按需再做,不放在本文件里): - * - 数据:TanStack Query 调 staleTime、列表 prefetch、占位骨架 - * - 时机:InteractionManager.runAfterInteractions 再拉非首屏接口 - * - 渲染:React.memo 重列表项、避免 Tab 切换时整树不必要 setState - */ - -import React, { useEffect, useState, useCallback } from 'react'; -import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { MessageListScreen } from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; - -// ==================== 类型定义 ==================== -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// Schedule Stack 类型定义 -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { course: any; relatedCourses?: any[] }; -}; - -// Profile Stack 类型定义 -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== Stack Navigators ==================== -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); - -// ==================== 常量 ==================== -const TAB_BAR_HEIGHT = 64; -const TAB_BAR_MARGIN = 14; -const TAB_BAR_FLOATING_MARGIN = 12; - -// ==================== 组件 ==================== - -/** - * Schedule Stack Navigator 组件 - */ -function ScheduleStackNavigatorComponent() { - return ( - - - - - ); -} - -/** - * Profile Stack Navigator 组件 - */ -function ProfileStackNavigatorComponent() { - return ( - - - - - - - - - - - ); -} - -/** - * 主组件:SimpleMobileTabNavigator - */ -export function SimpleMobileTabNavigator() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [activeTab, setActiveTab] = useState('HomeTab'); - /** 无嵌套 Stack 的 Tab:保留实例,避免每次切回都整页重挂载 */ - const [plainTabEverShown, setPlainTabEverShown] = useState({ - HomeTab: true, - MessageTab: false, - }); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - const handleTabPress = useCallback((tabName: TabName) => { - if (tabName === 'HomeTab' || tabName === 'MessageTab') { - setPlainTabEverShown((prev) => ({ ...prev, [tabName]: true })); - } - setActiveTab(tabName); - }, []); - - // 渲染 Tab Bar 图标 - const renderTabIcon = (tabName: TabName, isActive: boolean) => { - const iconColor = isActive ? colors.primary.main : colors.text.secondary; - const iconSize = isActive ? 26 : 24; - - switch (tabName) { - case 'HomeTab': - return ( - - ); - case 'MessageTab': - return ( - - ); - case 'ScheduleTab': - return ( - - ); - case 'ProfileTab': - return ( - - ); - default: - return null; - } - }; - - // 渲染 Tab Bar 标签 - const renderTabLabel = (tabName: TabName, isActive: boolean) => { - const labels: Record = { - HomeTab: '首页', - MessageTab: '消息', - ScheduleTab: '课表', - ProfileTab: '我的', - }; - - return ( - - {labels[tabName]} - - ); - }; - - return ( - - - {plainTabEverShown.HomeTab && ( - - - - )} - {plainTabEverShown.MessageTab && ( - - - - )} - {activeTab === 'ScheduleTab' && ( - - - - )} - {activeTab === 'ProfileTab' && ( - - - - )} - - - {/* Tab Bar */} - - {(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map( - (tabName) => { - const isActive = activeTab === tabName; - const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0; - - return ( - handleTabPress(tabName)} - activeOpacity={0.7} - > - - {renderTabIcon(tabName, isActive)} - {showBadge && ( - - - {messageUnreadCount > 99 ? '99+' : messageUnreadCount} - - - )} - - {renderTabLabel(tabName, isActive)} - - ); - } - )} - - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - content: { - flex: 1, - position: 'relative', - }, - tabScreen: { - ...StyleSheet.absoluteFillObject, - }, - tabScreenHidden: { - display: 'none', - }, - tabBar: { - position: 'absolute', - left: TAB_BAR_MARGIN, - right: TAB_BAR_MARGIN, - height: TAB_BAR_HEIGHT, - backgroundColor: colors.background.paper, - borderRadius: 24, - // 移除顶部边框,避免与内容之间出现线条 - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-around', - paddingHorizontal: 8, - ...shadows.lg, - // 叠在全屏内容之上(shadows.lg 含 elevation,需最后覆盖) - zIndex: 100, - elevation: 100, - }, - tabItem: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 6, - borderRadius: 18, - }, - tabItemActive: { - backgroundColor: `${colors.primary.main}15`, - }, - tabIconContainer: { - position: 'relative', - width: 38, - height: 30, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 15, - }, - tabLabel: { - fontSize: 12, - fontWeight: '600', - marginTop: -2, - letterSpacing: 0.2, - color: colors.text.secondary, - }, - tabLabelActive: { - color: colors.primary.main, - }, - badge: { - position: 'absolute', - top: -2, - right: -2, - backgroundColor: colors.error.main, - borderRadius: 10, - minWidth: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 4, - }, - badgeText: { - color: colors.primary.contrast, - fontSize: 10, - fontWeight: '600', - }, -}); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx deleted file mode 100644 index 771cd07..0000000 --- a/src/navigation/TabNavigator.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Tab 导航器 - * 处理底部标签导航(移动端) - */ -import React from 'react'; -import { View } from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import type { MainTabParamList } from './types'; -import { colors, shadows } from '../theme'; - -import { HomeNavigator } from './HomeNavigator'; -import { MessageNavigator } from './MessageNavigator'; -import { ScheduleNavigator } from './ScheduleNavigator'; -import { ProfileNavigator } from './ProfileNavigator'; - -const Tab = createBottomTabNavigator(); - -// 常量 -const MOBILE_TAB_FLOATING_MARGIN = 12; - -interface TabNavigatorProps { - unreadCount: number; -} - -export function TabNavigator({ unreadCount }: TabNavigatorProps) { - const insets = useSafeAreaInsets(); - - return ( - - ( - - - - ), - }} - /> - 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -import { StyleSheet } from 'react-native'; - -const styles = StyleSheet.create({ - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts new file mode 100644 index 0000000..55b5000 --- /dev/null +++ b/src/navigation/hrefs.ts @@ -0,0 +1,153 @@ +/** + * Expo Router 路径构建(单一事实来源,避免魔法字符串散落) + */ +import type { SystemMessageResponse } from '../types/dto'; +import { routePayloadCache } from '../stores/routePayloadCache'; + +export function hrefPostDetail(postId: string, scrollToComments?: boolean): string { + const q = scrollToComments ? '?scrollToComments=1' : ''; + return `/post/${encodeURIComponent(postId)}${q}`; +} + +export function hrefUserProfile(userId: string): string { + return `/user/${encodeURIComponent(userId)}`; +} + +export function hrefHome(): string { + return '/home'; +} + +export function hrefHomeSearch(): string { + return '/home/search'; +} + +export function hrefMessages(): string { + return '/messages'; +} + +export function hrefNotifications(): string { + return '/messages/notifications'; +} + +export function hrefSchedule(): string { + return '/schedule'; +} + +export function hrefScheduleCourse(courseId: string): string { + return `/schedule/course?courseId=${encodeURIComponent(courseId)}`; +} + +export function hrefProfileSettings(): string { + return '/profile/settings'; +} + +export function hrefProfileEdit(): string { + return '/profile/edit-profile'; +} + +export function hrefProfileSecurity(): string { + return '/profile/account-security'; +} + +export function hrefProfileNotifications(): string { + return '/profile/notification-settings'; +} + +export function hrefProfileBlocked(): string { + return '/profile/blocked-users'; +} + +export function hrefProfileMyPosts(): string { + return '/profile/my-posts'; +} + +export function hrefProfileBookmarks(): string { + return '/profile/bookmarks'; +} + +export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string { + if (mode === 'edit' && postId) { + return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`; + } + return '/posts/create'; +} + +export function hrefChat(params: { + conversationId: string; + userId?: string; + isGroupChat?: boolean; + groupId?: string; + groupName?: string; +}): string { + const { conversationId, userId, isGroupChat, groupId, groupName } = params; + const q = new URLSearchParams(); + if (userId) q.set('userId', userId); + if (isGroupChat) q.set('isGroupChat', '1'); + if (groupId != null && groupId !== '') q.set('groupId', String(groupId)); + if (groupName) q.set('groupName', groupName); + const qs = q.toString(); + return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`; +} + +export function hrefFollowList(userId: string, type: 'following' | 'followers'): string { + return `/users/${encodeURIComponent(userId)}/${type}`; +} + +export function hrefGroupCreate(): string { + return '/group/create'; +} + +export function hrefGroupJoin(): string { + return '/group/join'; +} + +export function hrefGroupInfo(groupId: string, conversationId?: string): string { + const base = `/group/${encodeURIComponent(groupId)}`; + if (!conversationId) return base; + return `${base}?conversationId=${encodeURIComponent(conversationId)}`; +} + +export function hrefGroupMembers(groupId: string): string { + return `/group/${encodeURIComponent(groupId)}/members`; +} + +export function hrefGroupRequestDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/request?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefGroupInviteDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/invite?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefPrivateChatInfo(params: { + conversationId: string; + userId: string; + userName?: string; + userAvatar?: string | null; +}): string { + const q = new URLSearchParams({ + conversationId: params.conversationId, + userId: params.userId, + }); + if (params.userName) q.set('userName', params.userName); + if (params.userAvatar != null) q.set('userAvatar', params.userAvatar ?? ''); + return `/chat/private-info?${q.toString()}`; +} + +export function hrefQrLoginConfirm(sessionId: string): string { + return `/qrcode/login/${encodeURIComponent(sessionId)}`; +} + +export function hrefAuthLogin(): string { + return '/login'; +} + +export function hrefAuthRegister(): string { + return '/register'; +} + +export function hrefAuthForgot(): string { + return '/forgot-password'; +} diff --git a/src/navigation/index.ts b/src/navigation/index.ts index 40e4f9c..f59562c 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -1,29 +1,4 @@ /** - * 导出所有导航组件 + * 导航路径工具(运行时导航请使用 expo-router) */ - -// 类型 -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, - TabName, - NavItemConfig, -} from './types'; - -export { AuthNavigator } from './AuthNavigator'; -export { HomeNavigator } from './HomeNavigator'; -export { MessageNavigator } from './MessageNavigator'; -export { ScheduleNavigator } from './ScheduleNavigator'; -export { ProfileNavigator } from './ProfileNavigator'; -export { TabNavigator } from './TabNavigator'; -export { DesktopNavigator } from './DesktopNavigator'; -export { RootNavigator } from './RootNavigator'; -export { default as MainNavigator } from './MainNavigator'; - -// 移动端简化导航 -export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; +export * from './hrefs'; diff --git a/src/navigation/paramUtils.ts b/src/navigation/paramUtils.ts new file mode 100644 index 0000000..3dfd707 --- /dev/null +++ b/src/navigation/paramUtils.ts @@ -0,0 +1,11 @@ +/** + * Expo Router 的 searchParams、动态段可能是 string | string[] + * 群 ID 等在应用内一律为 string;路由参数禁止 Number() 以免精度丢失 + */ +export function firstRouteParam(value: string | string[] | undefined): string | undefined { + if (value == null) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + if (raw == null) return undefined; + const s = String(raw).trim(); + return s === '' ? undefined : s; +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts deleted file mode 100644 index c8c7862..0000000 --- a/src/navigation/types.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * 导航类型定义 - * 定义所有导航栈的类型参数 - */ - -import { NavigatorScreenParams } from '@react-navigation/native'; -import type { SystemMessageResponse } from '../types/dto'; -import type { Course } from '../types/schedule'; - -// ==================== 主Tab导航参数 ==================== -export type MainTabParamList = { - HomeTab: NavigatorScreenParams; - MessageTab: NavigatorScreenParams; - ScheduleTab: NavigatorScreenParams; - ProfileTab: NavigatorScreenParams; -}; - -// ==================== 首页Stack ==================== -export type HomeStackParamList = { - Home: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - Search: undefined; - UserProfile: { userId: string }; - FollowList: { userId: string; type: 'following' | 'followers' }; -}; - -// ==================== 消息Stack ==================== -export type MessageStackParamList = { - MessageList: undefined; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - Notifications: undefined; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; -}; - -// ==================== 个人中心Stack ==================== -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== 课程表Stack ==================== -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { course: Course; relatedCourses: Course[] }; -}; - -// ==================== 认证Stack ==================== -export type AuthStackParamList = { - Login: undefined; - Register: undefined; - ForgotPassword: undefined; -}; - -// ==================== 根导航 ==================== -export type RootStackParamList = { - Main: NavigatorScreenParams; - Auth: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - UserProfile: { userId: string }; - CreatePost: - | undefined - | { - mode?: 'create' | 'edit'; - postId?: string; - }; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - FollowList: { userId: string; type: 'following' | 'followers' }; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; - QRCodeConfirm: { sessionId: string }; -}; - -// ==================== 全局类型声明 ==================== -declare global { - namespace ReactNavigation { - interface RootParamList extends RootStackParamList {} - } -} - -// ==================== 屏幕名称类型(用于路由)==================== -export type HomeScreenNames = keyof HomeStackParamList; -export type MessageScreenNames = keyof MessageStackParamList; -export type ProfileScreenNames = keyof ProfileStackParamList; -export type MainTabScreenNames = keyof MainTabParamList; -export type RootScreenNames = keyof RootStackParamList; - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// ==================== 导航项配置 ==================== -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} diff --git a/src/screens/auth/ForgotPasswordScreen.tsx b/src/screens/auth/ForgotPasswordScreen.tsx index 6d418ab..e870cc1 100644 --- a/src/screens/auth/ForgotPasswordScreen.tsx +++ b/src/screens/auth/ForgotPasswordScreen.tsx @@ -11,19 +11,15 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; -import { RootStackParamList } from '../../navigation/types'; import { authService, resolveAuthApiError } from '../../services/authService'; import { showPrompt } from '../../services/promptService'; -type ForgotPasswordNavigationProp = NativeStackNavigationProp; - export const ForgotPasswordScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const [email, setEmail] = useState(''); const [verificationCode, setVerificationCode] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -95,7 +91,7 @@ export const ForgotPasswordScreen: React.FC = () => { }); if (ok) { showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' }); - navigation.goBack(); + router.back(); } } catch (error: any) { showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' }); @@ -192,7 +188,7 @@ export const ForgotPasswordScreen: React.FC = () => { {loading ? : 重置密码} - navigation.goBack()}> + router.back()}> 返回登录 diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 000e7e6..9f3c3b0 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -22,24 +22,22 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type LoginNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const LoginScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const login = useAuthStore((state) => state.login); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const storeError = useAuthStore((state) => state.error); const setStoreError = useAuthStore((state) => state.setError); @@ -131,6 +129,12 @@ export const LoginScreen: React.FC = () => { } }, [storeError]); + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const clearError = () => { setErrorMsg(null); setStoreError(null); @@ -158,7 +162,7 @@ export const LoginScreen: React.FC = () => { // 跳转到注册页 const handleGoToRegister = () => { - navigation.navigate('Register' as any); + router.push(hrefs.hrefAuthRegister()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 @@ -297,7 +301,10 @@ export const LoginScreen: React.FC = () => { {/* 忘记密码 */} - navigation.navigate('ForgotPassword' as any)}> + router.push(hrefs.hrefAuthForgot())} + > 忘记密码? diff --git a/src/screens/auth/QRCodeConfirmScreen.tsx b/src/screens/auth/QRCodeConfirmScreen.tsx index 6d6ce38..6c25b09 100644 --- a/src/screens/auth/QRCodeConfirmScreen.tsx +++ b/src/screens/auth/QRCodeConfirmScreen.tsx @@ -10,28 +10,24 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../../navigation/types'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { qrcodeApi } from '../../services/authService'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; - -type QRCodeConfirmRouteProp = RouteProp; -type NavigationProp = NativeStackNavigationProp; +import { AppBackButton } from '../../components/common'; export const QRCodeConfirmScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { sessionId } = route.params; + const router = useRouter(); + const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>(); + const sessionId = sessionIdParam || ''; const [loading, setLoading] = useState(false); const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null); const [error, setError] = useState(null); useEffect(() => { - // 扫码 - handleScan(); - }, []); + if (!sessionId) return; + void handleScan(); + }, [sessionId]); const handleScan = async () => { try { @@ -42,7 +38,7 @@ export const QRCodeConfirmScreen: React.FC = () => { const errorMsg = err.response?.data?.message || '扫码失败'; setError(errorMsg); Alert.alert('扫码失败', errorMsg, [ - { text: '确定', onPress: () => navigation.goBack() } + { text: '确定', onPress: () => router.back() } ]); } finally { setLoading(false); @@ -54,7 +50,7 @@ export const QRCodeConfirmScreen: React.FC = () => { setLoading(true); await qrcodeApi.confirm(sessionId); Alert.alert('登录成功', '网页端已登录', [ - { text: '确定', onPress: () => navigation.goBack() } + { text: '确定', onPress: () => router.back() } ]); } catch (err: any) { const errorMsg = err.response?.data?.message || '确认登录失败'; @@ -70,7 +66,7 @@ export const QRCodeConfirmScreen: React.FC = () => { } catch (err) { // 忽略取消错误 } - navigation.goBack(); + router.back(); }; if (loading && !userInfo) { @@ -90,7 +86,7 @@ export const QRCodeConfirmScreen: React.FC = () => { {error} - navigation.goBack()}> + router.back()}> 返回 @@ -101,9 +97,7 @@ export const QRCodeConfirmScreen: React.FC = () => { return ( - - - + 确认登录 diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index d6fa740..fc5ff3d 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -22,25 +22,23 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { authService, resolveAuthApiError } from '../../services/authService'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type RegisterNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const RegisterScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const register = useAuthStore((state) => state.register); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); // 响应式布局 const { isLandscape, width } = useResponsive(); @@ -48,6 +46,12 @@ export const RegisterScreen: React.FC = () => { // 是否使用分栏布局 const isSplitLayout = width >= SPLIT_BREAKPOINT; + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const [username, setUsername] = useState(''); const [nickname, setNickname] = useState(''); const [email, setEmail] = useState(''); @@ -238,7 +242,7 @@ export const RegisterScreen: React.FC = () => { // 跳转到登录页 const handleGoToLogin = () => { - navigation.navigate('Login' as any); + router.push(hrefs.hrefAuthLogin()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 17d9297..efc2db1 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -21,17 +21,17 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; -import { Text, Button, ResponsiveContainer } from '../../components/common'; +import { Text, Button, ResponsiveContainer, AppBackButton } from '../../components/common'; import { postService, showPrompt, voteService } from '../../services'; import { ApiError } from '../../services/api'; import { uploadService } from '../../services/uploadService'; import VoteEditor from '../../components/business/VoteEditor'; import { useResponsive, useResponsiveValue } from '../../hooks'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; // Props 接口 interface CreatePostScreenProps { @@ -80,10 +80,11 @@ const getPublishErrorMessage = (error: unknown): string => { export const CreatePostScreen: React.FC = (props) => { const { onClose } = props; + const router = useRouter(); const navigation = useNavigation(); - const route = useRoute>(); - const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId; - const editPostID = route.params?.postId || ''; + const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>(); + const isEditMode = mode === 'edit' && !!postIdParam; + const editPostID = postIdParam || ''; // 响应式布局 const { isWideScreen, width } = useResponsive(); @@ -135,12 +136,11 @@ export const CreatePostScreen: React.FC = (props) => { React.useLayoutEffect(() => { navigation.setOptions({ + headerShown: true, title: isEditMode ? '编辑帖子' : '发布帖子', // 当作为 Modal 使用时,显示关闭按钮 headerLeft: onClose ? () => ( - - - + ) : undefined, }); }, [navigation, isEditMode, onClose]); @@ -156,7 +156,7 @@ export const CreatePostScreen: React.FC = (props) => { const existingPost = await postService.getPost(editPostID); if (!existingPost) { Alert.alert('提示', '帖子不存在或已被删除'); - navigation.goBack(); + router.back(); return; } setTitle(existingPost.title || ''); @@ -165,14 +165,14 @@ export const CreatePostScreen: React.FC = (props) => { } catch (error) { console.error('加载待编辑帖子失败:', error); Alert.alert('错误', '加载帖子失败,请稍后重试'); - navigation.goBack(); + router.back(); } finally { setLoadingPost(false); } }; loadPostForEdit(); - }, [isEditMode, editPostID, navigation]); + }, [isEditMode, editPostID, router]); // 选择图片 const handlePickImage = async () => { @@ -381,10 +381,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '投票帖已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { @@ -401,10 +398,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子内容已更新', duration: 2200, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { // 创建普通帖子 await postService.createPost({ @@ -418,10 +412,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } } } catch (error) { diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 8ace932..65320dc 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -19,8 +19,7 @@ import { Modal, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { colors, spacing, borderRadius, shadows } from '../../theme'; @@ -31,15 +30,12 @@ import { postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; -import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; - -type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; +import * as hrefs from '../../navigation/hrefs'; const TABS = ['最新', '关注', '热门']; const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire']; @@ -54,7 +50,7 @@ type ViewMode = 'list' | 'grid'; type PostType = 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); @@ -302,12 +298,12 @@ export const HomeScreen: React.FC = () => { // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigationService.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 点赞帖子 @@ -563,10 +559,12 @@ export const HomeScreen: React.FC = () => { /> } > - {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} - {/* 加载更多指示器 */} - {isLoading && displayPosts.length > 0 && ( - + + {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} + + {/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} + {isLoadingMore && displayPosts.length > 0 && ( + )} @@ -808,8 +806,12 @@ const styles = StyleSheet.create({ flex: 1, }, waterfallContainer: { - flexDirection: 'row', + width: '100%', flexGrow: 1, + }, + waterfallColumnsRow: { + width: '100%', + flexDirection: 'row', alignItems: 'flex-start', }, waterfallColumn: { @@ -844,7 +846,7 @@ const styles = StyleSheet.create({ height: 72, borderRadius: 36, }, - loadingMore: { + loadingMoreFooter: { paddingVertical: 20, alignItems: 'center', justifyContent: 'center', diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 56e24ff..5ae432b 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -23,8 +23,7 @@ import { Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; @@ -35,19 +34,20 @@ import { postService, commentService, uploadService, authService, showPrompt, vo import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; -import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; - -type NavigationProp = NativeStackNavigationProp; -type PostDetailRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; export const PostDetailScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); - const postId = route.params?.postId || ''; - const shouldScrollToComments = route.params?.scrollToComments || false; + const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{ + postId?: string; + scrollToComments?: string; + }>(); + const postId = postIdParam || ''; + const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true'; // 使用响应式 hook const { @@ -282,23 +282,18 @@ export const PostDetailScreen: React.FC = () => { const author = post.author; const handleBackPress = () => { - if (navigation.canGoBack()) { - navigation.goBack(); + if (router.canGoBack()) { + router.back(); return; } - navigation.navigate('Main', { - screen: 'HomeTab', - params: { screen: 'Home', params: undefined }, - }); + router.replace(hrefs.hrefHome()); }; navigation.setOptions({ headerBackVisible: false, headerTitleAlign: 'left', headerLeft: () => ( - - - + ), headerTitle: () => ( @@ -641,7 +636,7 @@ export const PostDetailScreen: React.FC = () => { Alert.alert('删除成功', '帖子已删除', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error) { @@ -654,15 +649,12 @@ export const PostDetailScreen: React.FC = () => { }, ], ); - }, [post, isDeleting, currentUser?.id, navigation]); + }, [post, isDeleting, currentUser?.id, router]); const handleEditPost = useCallback(() => { if (!post) return; - navigation.navigate('CreatePost', { - mode: 'edit', - postId: post.id, - }); - }, [navigation, post]); + router.push(hrefs.hrefCreatePost('edit', post.id)); + }, [router, post]); // 点击图片查看大图 const handleImagePress = useCallback((images: ImageGridItem[], index: number) => { @@ -999,7 +991,7 @@ export const PostDetailScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 渲染身份标识 @@ -1294,27 +1286,22 @@ export const PostDetailScreen: React.FC = () => { - {/* 评论标题 - 现代化设计 */} - - - - - - - 评论 - - - {post.comments_count} + {/* 评论标题 - 现代化简洁分区头 */} + + + + + 评论区 + + + {post.comments_count} + @@ -1396,9 +1383,9 @@ export const PostDetailScreen: React.FC = () => { @@ -1831,7 +1818,7 @@ const styles = StyleSheet.create({ postMetaInfo: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'flex-start', marginBottom: spacing.sm, }, metaInfoMain: { @@ -1928,38 +1915,42 @@ const styles = StyleSheet.create({ marginLeft: 6, fontWeight: '500', }, - commentTitle: { + commentSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + paddingTop: spacing.lg, + }, + commentSectionTitleBlock: { + flex: 1, + minWidth: 0, + }, + commentSectionTitleRow: { flexDirection: 'row', alignItems: 'center', }, - commentTitleLeft: { - flexDirection: 'row', - alignItems: 'center', - }, - commentTitleIconContainer: { - borderRadius: borderRadius.md, - backgroundColor: colors.primary.main, - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.sm, - }, - commentTitleText: { - fontWeight: '700', + commentSectionTitle: { + fontWeight: '800', color: colors.text.primary, + letterSpacing: -0.2, }, commentCountBadge: { - backgroundColor: colors.primary.light + '25', - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderRadius: borderRadius.md, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + paddingHorizontal: spacing.sm + 2, + paddingVertical: 3, + borderRadius: 999, marginLeft: spacing.sm, minWidth: 24, alignItems: 'center', }, commentCountText: { - fontSize: fontSizes.sm, + fontSize: fontSizes.xs, fontWeight: '700', - color: colors.primary.main, + color: colors.text.secondary, }, inputContainer: { backgroundColor: colors.background.paper, @@ -2067,17 +2058,26 @@ const styles = StyleSheet.create({ emptyCommentsContainer: { alignItems: 'center', justifyContent: 'center', - paddingVertical: spacing.xl * 2, + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + marginBottom: spacing.md, + paddingVertical: spacing.xl + spacing.md, paddingHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + backgroundColor: colors.background.paper, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, emptyCommentsIconWrapper: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: colors.primary.light + '18', + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, alignItems: 'center', justifyContent: 'center', - marginBottom: spacing.lg, + marginBottom: spacing.sm, }, emptyCommentsTitle: { fontSize: fontSizes.md, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 7a6e7b9..793720b 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -14,8 +14,7 @@ import { RefreshControl, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Post, User } from '../../types'; @@ -24,12 +23,10 @@ import { postService, authService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; import { useCursorPagination } from '../../hooks/useCursorPagination'; -type NavigationProp = NativeStackNavigationProp; - const TABS = ['帖子', '用户']; const DEFAULT_PAGE_SIZE = 20; @@ -37,12 +34,10 @@ type SearchType = 'posts' | 'users'; interface SearchScreenProps { onBack?: () => void; - navigation?: NavigationProp; } -export const SearchScreen: React.FC = ({ onBack, navigation: propNavigation }) => { - // 如果传入了 navigation 则使用传入的,否则使用 hook 获取的 - const navigation = propNavigation || useNavigation(); +export const SearchScreen: React.FC = ({ onBack }) => { + const router = useRouter(); const insets = useSafeAreaInsets(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); @@ -168,12 +163,12 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigation.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 统一处理 PostCard 的操作(搜索页不支持删除) @@ -515,7 +510,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: onBack ? onBack() : navigation.goBack()} + onPress={() => (onBack ? onBack() : router.back())} activeOpacity={0.85} > { - const navigation = useNavigation(); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); // 响应式布局 @@ -59,21 +61,9 @@ export const ChatScreen: React.FC = () => { // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页 useEffect(() => { if (isWideScreen) { - // 导航到大屏幕模式的主界面首页 - navigation.reset({ - index: 0, - routes: [ - { - name: 'Main', - params: { - screen: 'HomeTab', - params: { screen: 'Home' } - } - } - ] - }); + router.replace(hrefs.hrefHome()); } - }, [isWideScreen, navigation]); + }, [isWideScreen, router]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); @@ -228,16 +218,6 @@ export const ChatScreen: React.FC = () => { }; }, []); - const highlightMessageTemporarily = useCallback((messageId: string, duration = 1500) => { - if (replyHighlightTimerRef.current) { - clearTimeout(replyHighlightTimerRef.current); - } - setSelectedMessageId(messageId); - replyHighlightTimerRef.current = setTimeout(() => { - setSelectedMessageId(prev => (prev === messageId ? null : prev)); - }, duration); - }, [setSelectedMessageId]); - const handleReplyPreviewPress = useCallback((messageId: string) => { const targetId = String(messageId); const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); @@ -245,14 +225,13 @@ export const ChatScreen: React.FC = () => { replyTargetMessageIdRef.current = targetId; setBrowsingHistory(true); - highlightMessageTemporarily(targetId); flatListRef.current?.scrollToIndex({ index: targetIndex, animated: true, viewPosition: 0.5, }); - }, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]); + }, [displayMessages, flatListRef, setBrowsingHistory]); // 监听返回事件,刷新会话列表 useEffect(() => { @@ -399,7 +378,7 @@ export const ChatScreen: React.FC = () => { otherUser={otherUser} routeGroupName={routeGroupName} typingHint={typingHint} - onBack={() => navigation.goBack()} + onBack={() => router.back()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} onGroupInfoPress={handleGroupInfoPress} @@ -431,8 +410,8 @@ export const ChatScreen: React.FC = () => { initialNumToRender={14} maxToRenderPerBatch={10} updateCellsBatchingPeriod={50} - windowSize={9} - removeClippedSubviews={Platform.OS !== 'web'} + windowSize={15} + removeClippedSubviews={false} onScroll={handleMessageListScroll} onScrollBeginDrag={() => { isUserDraggingRef.current = true; diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index 2f35d03..5185947 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -16,8 +16,7 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; @@ -25,13 +24,10 @@ import { groupService } from '../../services/groupService'; import { uploadService } from '../../services/uploadService'; import { Avatar, Text, Button, Loading } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; -type NavigationProp = NativeStackNavigationProp; - const CreateGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); // 表单状态 const [groupName, setGroupName] = useState(''); @@ -138,7 +134,7 @@ const CreateGroupScreen: React.FC = () => { Alert.alert('成功', '群组创建成功', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 812a411..7c7846d 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -21,8 +21,8 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useFocusEffect } from '@react-navigation/native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; @@ -39,16 +39,14 @@ import { JoinType, } from '../../types/dto'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; +import { firstRouteParam } from '../../navigation/paramUtils'; +import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores/messageManager'; import { groupManager } from '../../stores/groupManager'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -type NavigationProp = NativeStackNavigationProp; -type GroupInfoRouteProp = RouteProp; - // 加群方式选项 const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [ { value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' }, @@ -57,9 +55,13 @@ const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: s ]; const GroupInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId, conversationId } = route.params; + const router = useRouter(); + const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{ + groupId?: string | string[]; + conversationId?: string | string[]; + }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; + const conversationId = firstRouteParam(conversationIdParam); const { currentUser } = useAuthStore(); // 响应式布局 @@ -112,6 +114,7 @@ const GroupInfoScreen: React.FC = () => { // 加载群组信息 const loadGroupInfo = useCallback(async () => { + if (!groupId) return; try { setLoading(true); @@ -138,7 +141,7 @@ const GroupInfoScreen: React.FC = () => { } catch (error) { console.error('加载群组信息失败:', error); Alert.alert('错误', '加载群组信息失败', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } finally { setLoading(false); @@ -394,7 +397,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.dissolveGroup(groupId); Alert.alert('成功', '群组已解散', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('解散群组失败:', error); @@ -420,7 +423,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.leaveGroup(groupId); Alert.alert('成功', '已退出群聊', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('退出群聊失败:', error); @@ -464,7 +467,7 @@ const GroupInfoScreen: React.FC = () => { // 跳转到成员管理 const goToMembers = () => { - navigation.navigate('GroupMembers' as any, { groupId }); + router.push(hrefs.hrefGroupMembers(groupId)); }; // 获取加群方式文本 @@ -485,8 +488,8 @@ const GroupInfoScreen: React.FC = () => { Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; diff --git a/src/screens/message/GroupInviteDetailScreen.tsx b/src/screens/message/GroupInviteDetailScreen.tsx index cb7788f..0110de4 100644 --- a/src/screens/message/GroupInviteDetailScreen.tsx +++ b/src/screens/message/GroupInviteDetailScreen.tsx @@ -1,25 +1,36 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacity } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; - import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { GroupMemberResponse } from '../../types/dto'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupInviteDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [loadingMembers, setLoadingMembers] = useState(false); @@ -35,7 +46,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -51,7 +62,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingMembers(true); try { - const res = await groupManager.getMembers(extra_data.group_id, 1, 100); + const res = await groupManager.getMembers(String(extra_data.group_id), 1, 100); const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin'); setMembers(admins); } catch { @@ -76,9 +87,9 @@ const GroupInviteDetailScreen: React.FC = () => { } setSubmitting(true); try { - await groupService.respondInvite(groupId, { flag, approve }); + await groupService.respondInvite(String(groupId), { flag, approve }); Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch { Alert.alert('操作失败', '请稍后重试'); @@ -87,7 +98,7 @@ const GroupInviteDetailScreen: React.FC = () => { } }; - const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]); + const groupNo = useMemo(() => String(extra_data?.group_id ?? '-'), [extra_data?.group_id]); const formatGroupNo = (id: string) => { if (id.length <= 12) return id; return `${id.slice(0, 6)}...${id.slice(-4)}`; @@ -168,6 +179,16 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, scrollView: { flex: 1, }, diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 15ffa5d..b862f07 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -19,8 +19,7 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; @@ -38,8 +37,7 @@ import { GroupMemberResponse, GroupRole, } from '../../types/dto'; -import { RootStackParamList } from '../../navigation/types'; - +import { firstRouteParam } from '../../navigation/paramUtils'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor'; @@ -50,9 +48,6 @@ const GRID_CONFIG = { desktop: { columns: 3, itemWidth: '31%' }, }; -type NavigationProp = NativeStackNavigationProp; -type GroupMembersRouteProp = RouteProp; - // 成员分组 interface MemberGroup { title: string; @@ -60,9 +55,8 @@ interface MemberGroup { } const GroupMembersScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId } = route.params; + const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; const { currentUser } = useAuthStore(); // 响应式布局 @@ -155,8 +149,9 @@ const GroupMembersScreen: React.FC = () => { // 初始加载 useEffect(() => { + if (!groupId) return; refresh(); - }, [groupId]); + }, [groupId, refresh]); // 按角色分组 const groupMembers = useCallback((): MemberGroup[] => { diff --git a/src/screens/message/GroupRequestDetailScreen.tsx b/src/screens/message/GroupRequestDetailScreen.tsx index 833be5a..6e00171 100644 --- a/src/screens/message/GroupRequestDetailScreen.tsx +++ b/src/screens/message/GroupRequestDetailScreen.tsx @@ -1,25 +1,38 @@ import React, { useEffect, useMemo, useState } from 'react'; import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { userManager } from '../../stores/userManager'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupRequestDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [submitting, setSubmitting] = useState(false); const [memberCount, setMemberCount] = useState(null); @@ -64,7 +77,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -90,18 +103,18 @@ const GroupRequestDetailScreen: React.FC = () => { setSubmitting(true); try { if (message.system_type === 'group_invite') { - await groupService.respondInvite(groupId, { + await groupService.respondInvite(String(groupId), { flag, approve, }); } else { - await groupService.reviewJoinRequest(groupId, { + await groupService.reviewJoinRequest(String(groupId), { flag, approve, }); } Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error) { Alert.alert('操作失败', '请稍后重试'); @@ -114,7 +127,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!applicantId) return; const user = await userManager.getUserById(applicantId); if (user?.id) { - navigation.navigate('UserProfile', { userId: String(user.id) }); + router.push(hrefs.hrefUserProfile(String(user.id))); } }; @@ -124,7 +137,7 @@ const GroupRequestDetailScreen: React.FC = () => { @@ -166,6 +179,16 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, scrollView: { flex: 1, }, diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 36125a9..c143cec 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -10,8 +10,7 @@ import { FlatList, RefreshControl, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, borderRadius } from '../../theme'; @@ -19,15 +18,12 @@ import Avatar from '../../components/common/Avatar'; import Text from '../../components/common/Text'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; -import { RootStackParamList } from '../../navigation/types'; import { GroupResponse, JoinType } from '../../types/dto'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { EmptyState } from '../../components/common'; -type NavigationProp = NativeStackNavigationProp; - const JoinGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); const [joiningGroupId, setJoiningGroupId] = useState(null); @@ -92,7 +88,7 @@ const JoinGroupScreen: React.FC = () => { Alert.alert('成功', '操作已提交', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { @@ -106,13 +102,13 @@ const JoinGroupScreen: React.FC = () => { } }; - const handleCopyGroupId = (groupId: string | number) => { - Clipboard.setString(String(groupId)); + const handleCopyGroupId = (groupId: string) => { + Clipboard.setString(groupId); Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 1414d72..8644d93 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -26,8 +26,8 @@ import { Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useIsFocused } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; @@ -43,9 +43,9 @@ import { useMarkAsRead, useMessageManagerConversations, } from '../../stores'; -import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; +import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 import { EmbeddedChat } from './components/EmbeddedChat'; import { ConversationListRow } from './components/ConversationListRow'; @@ -54,9 +54,6 @@ import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 import { QRCodeScanner } from '../../components/business/QRCodeScanner'; -type NavigationProp = NativeStackNavigationProp; -type MessageNavProp = NativeStackNavigationProp; - // 系统通知会话特殊ID const SYSTEM_MESSAGE_CHANNEL_ID = '-1'; @@ -76,7 +73,7 @@ interface SearchResultItem { * 支持响应式双栏布局 */ export const MessageListScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const isFocused = useIsFocused(); const insets = useSafeAreaInsets(); // 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight @@ -286,22 +283,25 @@ export const MessageListScreen: React.FC = () => { setSelectedConversation(conversation); } else if (conversation.type === 'group' && conversation.group) { // 群聊 - 窄屏下导航 - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) + ); } else { // 私聊 - 窄屏下导航 const currentUserId = useAuthStore.getState().currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) + ); } }); }; @@ -311,12 +311,12 @@ export const MessageListScreen: React.FC = () => { // 创建群聊 const handleCreateGroup = () => { - (navigation as any).navigate('CreateGroup'); + router.push(hrefs.hrefGroupCreate()); }; // 主动加群 const handleJoinGroup = () => { - (navigation as any).navigate('JoinGroup'); + router.push(hrefs.hrefGroupJoin()); }; const handleOpenActionMenu = () => { @@ -581,12 +581,13 @@ export const MessageListScreen: React.FC = () => { const conversation = await createConversation(String(user.id)); if (conversation) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: String(user.id), - userName: user.nickname || user.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: String(user.id), + isGroupChat: false, + }) + ); } } catch (error) { console.error('创建会话失败:', error); @@ -660,9 +661,7 @@ export const MessageListScreen: React.FC = () => { const renderSearchMode = () => ( - - - + void }> = ({ onBack }) => { const isFocused = useIsFocused(); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); - const navigation = useNavigation>(); + const router = useRouter(); // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题 const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); @@ -291,17 +290,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ) { const postId = await resolvePostId(message); if (postId) { - navigation.navigate('PostDetail', { postId }); + router.push(hrefs.hrefPostDetail(postId)); } } else if (system_type === 'follow') { // 关注 - 跳转到用户主页 if (extra_data?.actor_id_str) { - navigation.navigate('UserProfile', { userId: extra_data.actor_id_str }); + router.push(hrefs.hrefUserProfile(extra_data.actor_id_str)); } } else if (system_type === 'group_join_apply') { - navigation.navigate('GroupRequestDetail', { message }); + router.push(hrefs.hrefGroupRequestDetail(message)); } else if (system_type === 'group_invite') { - navigation.navigate('GroupInviteDetail', { message }); + router.push(hrefs.hrefGroupInviteDetail(message)); } // 其他类型暂不处理跳转 } catch (error) { @@ -316,7 +315,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null); if (actorId) { - navigation.navigate('UserProfile', { userId: actorId }); + router.push(hrefs.hrefUserProfile(actorId)); } }; @@ -350,13 +349,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack return ( {shouldShowBackButton ? ( - - - + ) : ( )} diff --git a/src/screens/message/PrivateChatInfoScreen.tsx b/src/screens/message/PrivateChatInfoScreen.tsx index 0a8c5e3..e7daa98 100644 --- a/src/screens/message/PrivateChatInfoScreen.tsx +++ b/src/screens/message/PrivateChatInfoScreen.tsx @@ -14,8 +14,7 @@ import { Switch, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { useAuthStore } from '../../stores'; @@ -30,16 +29,20 @@ import { messageManager } from '../../stores/messageManager'; import { userManager } from '../../stores/userManager'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; - -type NavigationProp = NativeStackNavigationProp; -type PrivateChatInfoRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; const PrivateChatInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { conversationId, userId, userName, userAvatar } = route.params; + const router = useRouter(); + const raw = useLocalSearchParams<{ + conversationId?: string; + userId?: string; + userName?: string; + userAvatar?: string; + }>(); + const conversationId = raw.conversationId ?? ''; + const userId = raw.userId ?? ''; + const userName = raw.userName; + const userAvatar = raw.userAvatar; const { currentUser } = useAuthStore(); // 用户信息状态 @@ -150,8 +153,7 @@ const PrivateChatInfoScreen: React.FC = () => { // 查看用户资料 const handleViewProfile = () => { - // 使用导航服务跳转到用户资料页面 - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 举报/投诉 @@ -219,7 +221,7 @@ const PrivateChatInfoScreen: React.FC = () => { } setIsBlocked(true); messageManager.removeConversation(conversationId); - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); Alert.alert('已拉黑', '你已成功拉黑该用户'); } catch (error) { Alert.alert('错误', '拉黑失败,请稍后重试'); @@ -245,7 +247,7 @@ const PrivateChatInfoScreen: React.FC = () => { await messageService.deleteConversationForSelf(conversationId); messageManager.removeConversation(conversationId); // 返回消息列表 - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); } catch (error) { const msg = error instanceof ApiError ? error.message : '删除聊天失败'; Alert.alert('错误', msg); diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 437a6d1..71cb2a1 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -7,10 +7,10 @@ import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { Avatar, Text } from '../../../../components/common'; +import { Avatar, Text, AppBackButton } from '../../../../components/common'; import { colors, spacing } from '../../../../theme'; import { chatScreenStyles as baseStyles } from './styles'; -import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { ChatHeaderProps } from './types'; export const ChatHeader: React.FC = ({ @@ -26,7 +26,6 @@ export const ChatHeader: React.FC = ({ isWideScreen: propIsWideScreen, }) => { // 响应式布局 - const { width } = useResponsive(); // 使用 768px 作为大屏幕和小屏幕的分界线 const isWideScreenHook = useBreakpointGTE('lg'); // 优先使用 props 传入的 isWideScreen,否则使用 hook @@ -74,12 +73,7 @@ export const ChatHeader: React.FC = ({ {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( - - - + ) : ( )} @@ -131,14 +125,14 @@ export const ChatHeader: React.FC = ({ style={styles.moreButton} onPress={onGroupInfoPress} > - + ) : ( - + )} diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index b4feaa0..3fb6dc7 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -120,7 +120,7 @@ export const MessageBubble: React.FC = ({ const data = s.data as ImageSegmentData; return { id: `img-${message.id}-${idx}`, - url: data.url, + url: data.url || data.thumbnail_url || '', thumbnail_url: data.thumbnail_url, width: data.width, height: data.height, @@ -128,7 +128,6 @@ export const MessageBubble: React.FC = ({ }); // 检查当前消息是否被选中 - const isSelected = selectedMessageId === String(message.id); // 获取发送者信息(群聊)- 供子组件使用 const getSenderInfo = React.useCallback((senderId: string): SenderInfo => { @@ -307,7 +306,6 @@ export const MessageBubble: React.FC = ({ isMe ? styles.myBubble : styles.theirBubble, hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble, - isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble), ]}> = ({ onReplyPress={onReplyPress} onImagePress={(url) => { // 查找点击的图片索引 - const clickIndex = imageSegments.findIndex(img => img.url === url); + const clickIndex = imageSegments.findIndex( + img => img.url === url || img.thumbnail_url === url + ); if (clickIndex !== -1 && onImagePress) { onImagePress(imageSegments, clickIndex); } @@ -419,7 +419,7 @@ export const MessageBubble: React.FC = ({ )} - + {/* 群聊模式:显示发送者昵称 */} {isGroupChat && !isMe && senderInfo && ( {senderInfo.nickname} @@ -483,16 +483,4 @@ const segmentStyles = StyleSheet.create({ }, }); -// 使用 React.memo 优化渲染性能 -export default React.memo(MessageBubble, (prevProps, nextProps) => { - // 自定义比较逻辑:只比较关键属性 - return ( - prevProps.message.id === nextProps.message.id && - prevProps.message.status === nextProps.message.status && - prevProps.selectedMessageId === nextProps.selectedMessageId && - prevProps.currentUserId === nextProps.currentUserId && - prevProps.isGroupChat === nextProps.isGroupChat && - prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq && - prevProps.index === nextProps.index - ); -}); +export default MessageBubble; diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index a8eaa7c..502024e 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -3,7 +3,7 @@ * 用于渲染消息链中的各种 Segment 类型 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, Text, @@ -131,11 +131,21 @@ const ImageSegment: React.FC<{ const pressPositionRef = useRef({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const [loadError, setLoadError] = useState(false); - const imageUrl = data.thumbnail_url || data.url; + const [currentImageUri, setCurrentImageUri] = useState(data.thumbnail_url || data.url || ''); + const imageUrl = currentImageUri; + const pressUrl = data.url || data.thumbnail_url || ''; + const stableImageKey = `${data.url || ''}|${data.thumbnail_url || ''}|${data.width || 0}x${data.height || 0}`; // 如果没有有效的图片URL,显示图片占位符 const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; + useEffect(() => { + // 切换消息图片时重置状态,避免列表复用导致旧状态污染 + setCurrentImageUri(data.thumbnail_url || data.url || ''); + setDimensions(null); + setLoadError(false); + }, [data.thumbnail_url, data.url]); + useEffect(() => { // 重置状态 setLoadError(false); @@ -169,9 +179,7 @@ const ImageSegment: React.FC<{ // 添加超时处理,防止高分辨率图片加载卡住 const timeoutId = setTimeout(() => { - if (!dimensions) { - setDimensions(IMAGE_FALLBACK_SIZE); - } + setDimensions(prev => prev || IMAGE_FALLBACK_SIZE); }, 3000); // 否则从图片获取实际尺寸 @@ -196,7 +204,7 @@ const ImageSegment: React.FC<{ setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) }); }, - (error) => { + () => { clearTimeout(timeoutId); // 获取失败时使用默认 4:3 比例 setDimensions(IMAGE_FALLBACK_SIZE); @@ -221,10 +229,9 @@ const ImageSegment: React.FC<{ if (!hasValidUrl || loadError) { return ( hasValidUrl && onImagePress?.(data.url)} + onPress={() => hasValidUrl && onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -250,10 +257,9 @@ const ImageSegment: React.FC<{ if (!dimensions) { return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -272,10 +278,9 @@ const ImageSegment: React.FC<{ return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -287,11 +292,20 @@ const ImageSegment: React.FC<{ height: dimensions.height, borderRadius: 8, }} + recyclingKey={stableImageKey} contentFit="cover" - cachePolicy="disk" + cachePolicy="memory-disk" priority="normal" transition={200} + onLoadStart={() => { + setLoadError(false); + }} onError={() => { + // 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率 + if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) { + setCurrentImageUri(data.url); + return; + } setLoadError(true); }} /> @@ -680,7 +694,9 @@ export const MessageSegmentsRenderer: React.FC<{ {/* 其他 Segment 内容 */} {otherSegments.map((segment, index) => ( - + {renderSegment(segment, renderProps)} ))} @@ -738,17 +754,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 2, }, atTextMe: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: '#4A88C7', + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atTextOther: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: '#4A88C7', + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atHighlight: { - backgroundColor: 'rgba(25, 118, 210, 0.2)', + backgroundColor: 'rgba(74, 136, 199, 0.2)', borderRadius: 4, paddingHorizontal: 4, }, @@ -966,7 +982,7 @@ const styles = StyleSheet.create({ elevation: 0, }, replyMe: { - backgroundColor: 'rgba(25, 118, 210, 0.1)', + backgroundColor: 'rgba(74, 136, 199, 0.12)', }, replyOther: { backgroundColor: 'rgba(0, 0, 0, 0.03)', @@ -984,7 +1000,7 @@ const styles = StyleSheet.create({ replySender: { fontSize: 13, fontWeight: '600', - color: '#1976D2', + color: '#4A88C7', marginRight: 6, flexShrink: 0, }, diff --git a/src/screens/message/components/ChatScreen/bubbleStyles.ts b/src/screens/message/components/ChatScreen/bubbleStyles.ts index 1739007..a5a3667 100644 --- a/src/screens/message/components/ChatScreen/bubbleStyles.ts +++ b/src/screens/message/components/ChatScreen/bubbleStyles.ts @@ -23,8 +23,8 @@ export const bubbleColors = { }, // 被回复的消息高亮 replyHighlight: { - background: 'rgba(255, 107, 53, 0.08)', - borderLeft: '#FF6B35', + background: 'rgba(74, 136, 199, 0.1)', + borderLeft: '#4A88C7', }, }; diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 1a2bd32..b438fc9 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -200,7 +200,7 @@ export const chatScreenStyles = StyleSheet.create({ }, senderName: { fontSize: 13, - color: '#1976D2', + color: '#4A88C7', marginBottom: 4, marginLeft: 4, fontWeight: '600', @@ -215,7 +215,7 @@ export const chatScreenStyles = StyleSheet.create({ minWidth: 60, }, myBubble: { - backgroundColor: '#E3F2FD', // Telegram风格的浅蓝色 + backgroundColor: '#DFF2FF', // Telegram风格的浅蓝色 borderBottomRightRadius: 4, // 右下角尖,箭头在右下 shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, @@ -255,17 +255,23 @@ export const chatScreenStyles = StyleSheet.create({ // 选中状态 selectedBubble: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', + }, + myMessageContentPanel: { + backgroundColor: '#DFF2FF', + borderRadius: 14, + paddingHorizontal: 4, + paddingBottom: 4, }, mySelectedBubble: { - backgroundColor: '#0051D5', + backgroundColor: '#CFEAFF', borderWidth: 2, - borderColor: 'rgba(255,255,255,0.5)', + borderColor: '#7FB6E6', }, theirSelectedBubble: { - backgroundColor: '#E8F1FF', + backgroundColor: '#EEF5FC', borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', }, selectedText: { // 文本选中时的样式 @@ -539,7 +545,7 @@ export const chatScreenStyles = StyleSheet.create({ marginRight: spacing.xs, }, panelTabActive: { - backgroundColor: '#E3F2FD', + backgroundColor: '#DFF2FF', }, panelTabEmoji: { fontSize: 24, @@ -1025,11 +1031,11 @@ export const chatScreenStyles = StyleSheet.create({ // 表情包选择状态 stickerItemSelected: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', }, stickerCheckOverlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 122, 255, 0.1)', + backgroundColor: 'rgba(127, 182, 230, 0.14)', alignItems: 'flex-end', justifyContent: 'flex-start', padding: 4, @@ -1045,8 +1051,8 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'center', }, stickerCheckBoxSelected: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', + backgroundColor: '#7FB6E6', + borderColor: '#7FB6E6', }, // 表情管理模态框 @@ -1077,12 +1083,12 @@ export const chatScreenStyles = StyleSheet.create({ }, manageDoneText: { fontSize: 16, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageSelectText: { fontSize: 16, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageInfoBar: { @@ -1120,7 +1126,7 @@ export const chatScreenStyles = StyleSheet.create({ }, manageSelectAllText: { fontSize: 15, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageDeleteButton: { diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 8062eec..f5fecc4 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -17,7 +17,7 @@ import { KeyboardEvent, Alert, } from 'react-native'; -import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; +import { useLocalSearchParams, router } from 'expo-router'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import * as ImagePicker from 'expo-image-picker'; @@ -30,8 +30,8 @@ import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../.. import { groupService } from '../../../../services/groupService'; import { userManager } from '../../../../stores/userManager'; import { groupManager } from '../../../../stores/groupManager'; -import { RootStackParamList } from '../../../../navigation/types'; -import { navigationService } from '../../../../infrastructure/navigation/navigationService'; +import * as hrefs from '../../../../navigation/hrefs'; +import { firstRouteParam } from '../../../../navigation/paramUtils'; import { GroupMessage, PanelType, @@ -46,8 +46,6 @@ import { clearConversationMessages, } from '../../../../services/database'; -type ChatRouteProp = RouteProp; - export const useChatScreen = () => { const getSendErrorMessage = useCallback((error: unknown, fallback: string) => { if (error instanceof ApiError && error.message) { @@ -56,17 +54,30 @@ export const useChatScreen = () => { return fallback; }, []); - const route = useRoute(); - const navigation = useNavigation(); - - // 路由参数 - const routeParams = useMemo(() => ({ - conversationId: route.params?.conversationId || null, - userId: route.params?.userId || null, - isGroupChat: route.params?.isGroupChat || false, - groupId: route.params?.groupId, - groupName: route.params?.groupName, - }), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.groupName]); + const rawParams = useLocalSearchParams<{ + conversationId?: string | string[]; + userId?: string | string[]; + isGroupChat?: string | string[]; + groupId?: string | string[]; + groupName?: string | string[]; + }>(); + // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 + const routeParams = useMemo(() => { + const isGroupFlag = firstRouteParam(rawParams.isGroupChat); + return { + conversationId: firstRouteParam(rawParams.conversationId) ?? null, + userId: firstRouteParam(rawParams.userId) ?? null, + isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true', + groupId: firstRouteParam(rawParams.groupId), + groupName: firstRouteParam(rawParams.groupName), + }; + }, [ + rawParams.conversationId, + rawParams.userId, + rawParams.isGroupChat, + rawParams.groupId, + rawParams.groupName, + ]); const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams; @@ -399,8 +410,30 @@ export const useChatScreen = () => { console.error('获取成员信息失败:', error); } } catch (error) { + const isGroupNotFound = + error instanceof ApiError && + (error.code === 404 || + error.message === '群组不存在' || + error.message.includes('群组不存在')); + + if (isGroupNotFound) { + Alert.alert('提示', '该群组不存在或已解散', [ + { + text: '确定', + onPress: () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace(hrefs.hrefMessages()); + } + }, + }, + ]); + return; + } + console.error('获取群组信息失败:', error); - Alert.alert('错误', '无法获取群组信息'); + Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息')); } }; @@ -1129,7 +1162,7 @@ export const useChatScreen = () => { // 点击头像跳转到用户主页 const handleAvatarPress = useCallback((userId: string) => { if (userId && userId !== currentUserId) { - navigationService.navigate('UserProfile', { userId: String(userId) }); + router.push(hrefs.hrefUserProfile(String(userId))); } }, [currentUserId]); @@ -1198,31 +1231,27 @@ export const useChatScreen = () => { // 导航到群组信息或用户资料 const navigateToInfo = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id) { - navigationService.navigate('UserProfile', { userId: String(otherUser.id) }); + router.push(hrefs.hrefUserProfile(String(otherUser.id))); } - }, [navigation, isGroupChat, routeGroupId, otherUser]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); // 导航到聊天管理页面 const navigateToChatSettings = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id && conversationId) { - navigation.navigate('PrivateChatInfo' as any, { - conversationId: conversationId, - userId: String(otherUser.id), - userName: otherUser.nickname, - userAvatar: otherUser.avatar, - }); + router.push( + hrefs.hrefPrivateChatInfo({ + conversationId, + userId: String(otherUser.id), + userName: otherUser.nickname, + userAvatar: otherUser.avatar, + }) + ); } - }, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); return { // 状态 diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 8f7eecf..5357c57 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -16,17 +16,18 @@ import { Dimensions, Animated, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Image as ExpoImage } from 'expo-image'; import { colors, spacing, fontSizes, shadows } from '../../../theme'; import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; -import { Avatar, Text, ImageGallery } from '../../../components/common'; +import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { extractTextFromSegments } from '../../../types/dto'; import { useBreakpointGTE } from '../../../hooks/useResponsive'; import { useMarkAsRead } from '../../../stores/messageManagerHooks'; import { messageService } from '../../../services'; +import * as hrefs from '../../../navigation/hrefs'; import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; @@ -39,7 +40,7 @@ interface EmbeddedChatProps { } export const EmbeddedChat: React.FC = ({ conversation, onBack }) => { - const navigation = useNavigation(); + const router = useRouter(); const currentUser = useAuthStore(state => state.currentUser); // 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线 @@ -190,21 +191,24 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 导航到完整聊天页面 const handleNavigateToFullChat = () => { if (isGroupChat && conversation.group) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) as any + ); } else { const currentUserId = currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) as any + ); } }; @@ -407,9 +411,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( - - - + ) : ( )} diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index dc40c81..fd282b9 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -9,14 +9,16 @@ import { Alert, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; +import { useRouter } from 'expo-router'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { useResponsive } from '../../hooks'; +import * as hrefs from '../../navigation/hrefs'; export const BlockedUsersScreen: React.FC = () => { + const router = useRouter(); const { isMobile } = useResponsive(); const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); @@ -78,7 +80,7 @@ export const BlockedUsersScreen: React.FC = () => { navigationService.navigate('UserProfile', { userId: item.id })} + onPress={() => router.push(hrefs.hrefUserProfile(item.id))} > diff --git a/src/screens/profile/FollowListScreen.tsx b/src/screens/profile/FollowListScreen.tsx index dafa64a..f6802a0 100644 --- a/src/screens/profile/FollowListScreen.tsx +++ b/src/screens/profile/FollowListScreen.tsx @@ -16,23 +16,19 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { useAuthStore, useUserStore } from '../../stores'; import { authService } from '../../services'; import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useColumnCount } from '../../hooks'; -type NavigationProp = NativeStackNavigationProp; -type FollowListRouteProp = RouteProp; - const FollowListScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { userId, type } = route.params; + const router = useRouter(); + const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>(); + const type = typeParam === 'followers' ? 'followers' : 'following'; const { currentUser } = useAuthStore(); const { followUser, unfollowUser } = useUserStore(); @@ -135,7 +131,7 @@ const FollowListScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (targetUserId: string) => { if (targetUserId !== currentUser?.id) { - navigation.push('UserProfile', { userId: targetUserId }); + router.push(hrefs.hrefUserProfile(targetUserId)); } }; @@ -182,11 +178,11 @@ const FollowListScreen: React.FC = () => { @{item.username} - {item.bio && ( + {item.bio?.trim() ? ( {item.bio} - )} + ) : null} {!isSelf && (