dev #5
136
App.tsx
136
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() {
|
export default function App() {
|
||||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
return null;
|
||||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(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 (
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<PaperProvider theme={paperTheme}>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<MainNavigator />
|
|
||||||
<AppPromptBar />
|
|
||||||
<AppDialogHost />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</PaperProvider>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
96
app/(app)/(tabs)/_layout.tsx
Normal file
96
app/(app)/(tabs)/_layout.tsx
Normal file
@@ -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 (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary.main,
|
||||||
|
tabBarInactiveTintColor: colors.text.secondary,
|
||||||
|
tabBarStyle: hideTabBar
|
||||||
|
? { display: 'none', height: 0, overflow: 'hidden' }
|
||||||
|
: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: TAB_BAR_MARGIN,
|
||||||
|
right: TAB_BAR_MARGIN,
|
||||||
|
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
|
||||||
|
height: TAB_BAR_HEIGHT,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
...shadows.lg,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
elevation: 100,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
tabBarShowLabel: true,
|
||||||
|
tabBarLabelStyle: { fontSize: 12, fontWeight: '600', marginTop: -2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="home"
|
||||||
|
options={{
|
||||||
|
title: '首页',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={focused ? 'home' : 'home-outline'}
|
||||||
|
size={focused ? 26 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="messages"
|
||||||
|
options={{
|
||||||
|
title: '消息',
|
||||||
|
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined,
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={focused ? 'message-text' : 'message-text-outline'}
|
||||||
|
size={focused ? 26 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="schedule"
|
||||||
|
options={{
|
||||||
|
title: '课表',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<MaterialCommunityIcons name="calendar-today" size={focused ? 26 : 24} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: '我的',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={focused ? 'account' : 'account-outline'}
|
||||||
|
size={focused ? 26 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/_layout.tsx
Normal file
5
app/(app)/(tabs)/home/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function HomeStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/index.tsx
Normal file
5
app/(app)/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { HomeScreen } from '../../../../src/screens/home';
|
||||||
|
|
||||||
|
export default function HomeRoute() {
|
||||||
|
return <HomeScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/search.tsx
Normal file
5
app/(app)/(tabs)/home/search.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SearchScreen } from '../../../../src/screens/home';
|
||||||
|
|
||||||
|
export default function HomeSearchRoute() {
|
||||||
|
return <SearchScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/_layout.tsx
Normal file
5
app/(app)/(tabs)/messages/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function MessagesStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/index.tsx
Normal file
5
app/(app)/(tabs)/messages/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MessageListScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function MessagesRoute() {
|
||||||
|
return <MessageListScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/notifications.tsx
Normal file
5
app/(app)/(tabs)/messages/notifications.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationsScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function NotificationsRoute() {
|
||||||
|
return <NotificationsScreen />;
|
||||||
|
}
|
||||||
36
app/(app)/(tabs)/profile/_layout.tsx
Normal file
36
app/(app)/(tabs)/profile/_layout.tsx
Normal file
@@ -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 (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
...headerOptions,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
|
||||||
|
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
|
||||||
|
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
|
||||||
|
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
|
||||||
|
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||||
|
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||||
|
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/account-security.tsx
Normal file
5
app/(app)/(tabs)/profile/account-security.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AccountSecurityScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function AccountSecurityRoute() {
|
||||||
|
return <AccountSecurityScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/blocked-users.tsx
Normal file
5
app/(app)/(tabs)/profile/blocked-users.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BlockedUsersScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function BlockedUsersRoute() {
|
||||||
|
return <BlockedUsersScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/bookmarks.tsx
Normal file
5
app/(app)/(tabs)/profile/bookmarks.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function BookmarksRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/edit-profile.tsx
Normal file
5
app/(app)/(tabs)/profile/edit-profile.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { EditProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function EditProfileRoute() {
|
||||||
|
return <EditProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/index.tsx
Normal file
5
app/(app)/(tabs)/profile/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function ProfileRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/my-posts.tsx
Normal file
5
app/(app)/(tabs)/profile/my-posts.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function MyPostsRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/notification-settings.tsx
Normal file
5
app/(app)/(tabs)/profile/notification-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function NotificationSettingsRoute() {
|
||||||
|
return <NotificationSettingsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/settings.tsx
Normal file
5
app/(app)/(tabs)/profile/settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SettingsScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function SettingsRoute() {
|
||||||
|
return <SettingsScreen />;
|
||||||
|
}
|
||||||
17
app/(app)/(tabs)/schedule/_layout.tsx
Normal file
17
app/(app)/(tabs)/schedule/_layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function ScheduleStackLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="course"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'transparentModal',
|
||||||
|
animation: 'fade',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/schedule/course.tsx
Normal file
5
app/(app)/(tabs)/schedule/course.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CourseDetailScreen } from '../../../../src/screens/schedule';
|
||||||
|
|
||||||
|
export default function CourseDetailRoute() {
|
||||||
|
return <CourseDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/schedule/index.tsx
Normal file
5
app/(app)/(tabs)/schedule/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ScheduleScreen } from '../../../../src/screens/schedule';
|
||||||
|
|
||||||
|
export default function ScheduleRoute() {
|
||||||
|
return <ScheduleScreen />;
|
||||||
|
}
|
||||||
19
app/(app)/_layout.tsx
Normal file
19
app/(app)/_layout.tsx
Normal file
@@ -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 <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppRouteStack />;
|
||||||
|
}
|
||||||
28
app/(app)/_layout.web.tsx
Normal file
28
app/(app)/_layout.web.tsx
Normal file
@@ -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 <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useDesktopShell) {
|
||||||
|
return <AppDesktopShell />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppRouteStack />;
|
||||||
|
}
|
||||||
5
app/(app)/chat/[conversationId].tsx
Normal file
5
app/(app)/chat/[conversationId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function ChatRoute() {
|
||||||
|
return <ChatScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/chat/private-info.tsx
Normal file
5
app/(app)/chat/private-info.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivateChatInfoScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function PrivateChatInfoRoute() {
|
||||||
|
return <PrivateChatInfoScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/[groupId]/index.tsx
Normal file
5
app/(app)/group/[groupId]/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { GroupInfoScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function GroupInfoRoute() {
|
||||||
|
return <GroupInfoScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/[groupId]/members.tsx
Normal file
5
app/(app)/group/[groupId]/members.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { GroupMembersScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function GroupMembersRoute() {
|
||||||
|
return <GroupMembersScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/create.tsx
Normal file
5
app/(app)/group/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CreateGroupScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function CreateGroupRoute() {
|
||||||
|
return <CreateGroupScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/invite.tsx
Normal file
5
app/(app)/group/invite.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
|
||||||
|
|
||||||
|
export default function GroupInviteRoute() {
|
||||||
|
return <GroupInviteDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/join.tsx
Normal file
5
app/(app)/group/join.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { JoinGroupScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function JoinGroupRoute() {
|
||||||
|
return <JoinGroupScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/request.tsx
Normal file
5
app/(app)/group/request.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
|
||||||
|
|
||||||
|
export default function GroupRequestRoute() {
|
||||||
|
return <GroupRequestDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/posts/create.tsx
Normal file
5
app/(app)/posts/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CreatePostScreen } from '../../../src/screens/create';
|
||||||
|
|
||||||
|
export default function CreatePostRoute() {
|
||||||
|
return <CreatePostScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/qrcode/login/[sessionId].tsx
Normal file
5
app/(app)/qrcode/login/[sessionId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
|
||||||
|
|
||||||
|
export default function QrLoginConfirmRoute() {
|
||||||
|
return <QRCodeConfirmScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/users/[userId]/[type].tsx
Normal file
5
app/(app)/users/[userId]/[type].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
|
||||||
|
|
||||||
|
export default function FollowListRoute() {
|
||||||
|
return <FollowListScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/_layout.tsx
Normal file
5
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(auth)/forgot-password.tsx
Normal file
5
app/(auth)/forgot-password.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ForgotPasswordScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function ForgotPasswordRoute() {
|
||||||
|
return <ForgotPasswordScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/login.tsx
Normal file
5
app/(auth)/login.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { LoginScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function LoginRoute() {
|
||||||
|
return <LoginScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/register.tsx
Normal file
5
app/(auth)/register.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RegisterScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function RegisterRoute() {
|
||||||
|
return <RegisterScreen />;
|
||||||
|
}
|
||||||
173
app/_layout.tsx
Normal file
173
app/_layout.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={gateStyles.loading}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationBootstrap() {
|
||||||
|
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(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 (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<PaperProvider theme={paperTheme}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<SessionGate>
|
||||||
|
<NotificationBootstrap />
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="post/[postId]"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: '',
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="user/[userId]"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: '用户主页',
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</SessionGate>
|
||||||
|
<AppPromptBar />
|
||||||
|
<AppDialogHost />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</PaperProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateStyles = StyleSheet.create({
|
||||||
|
loading: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
});
|
||||||
11
app/index.tsx
Normal file
11
app/index.tsx
Normal file
@@ -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 <Redirect href="/home" />;
|
||||||
|
}
|
||||||
|
return <Redirect href="/login" />;
|
||||||
|
}
|
||||||
5
app/post/[postId].tsx
Normal file
5
app/post/[postId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PostDetailScreen } from '../../src/screens/home';
|
||||||
|
|
||||||
|
export default function PostDetailRoute() {
|
||||||
|
return <PostDetailScreen />;
|
||||||
|
}
|
||||||
5
app/user/[userId].tsx
Normal file
5
app/user/[userId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { UserScreen } from '../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function UserProfileRoute() {
|
||||||
|
return <UserScreen />;
|
||||||
|
}
|
||||||
9
index.ts
9
index.ts
@@ -1,8 +1 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
import 'expo-router/entry';
|
||||||
|
|
||||||
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);
|
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -19,7 +19,8 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"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-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
"expo-dev-client": "~55.0.10",
|
"expo-dev-client": "~55.0.10",
|
||||||
@@ -5542,6 +5543,18 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-camera": {
|
||||||
"version": "55.0.10",
|
"version": "55.0.10",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"main": "index.ts",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"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-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
"expo-dev-client": "~55.0.10",
|
"expo-dev-client": "~55.0.10",
|
||||||
|
|||||||
@@ -1,107 +1,91 @@
|
|||||||
/**
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
* 桌面端导航器
|
|
||||||
* 为平板/桌面设备提供侧边栏导航体验
|
|
||||||
*/
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Text,
|
Text,
|
||||||
Animated,
|
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
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 { colors, shadows } from '../theme';
|
||||||
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
|
import { useTotalUnreadCount } from '../stores';
|
||||||
|
import { AppRouteStack } from './AppRouteStack';
|
||||||
|
|
||||||
// 导入屏幕
|
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||||
import { HomeScreen } from '../screens/home';
|
|
||||||
import { ScheduleScreen } from '../screens/schedule';
|
|
||||||
import { MessageListScreen } from '../screens/message';
|
|
||||||
import { ProfileScreen } from '../screens/profile';
|
|
||||||
|
|
||||||
// 侧边栏常量
|
const SIDEBAR_WIDTH_DESKTOP = 240;
|
||||||
const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS;
|
const SIDEBAR_WIDTH_TABLET = 200;
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH = 72;
|
||||||
|
|
||||||
// 导航项配置
|
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
|
||||||
const NAV_ITEMS: NavItemConfig[] = [
|
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
|
||||||
{ name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' },
|
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
|
||||||
{ name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' },
|
{ name: 'ScheduleTab', label: '课表', href: '/schedule', icon: 'calendar-today', iconOutline: 'calendar-today' },
|
||||||
{ name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' },
|
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
|
||||||
{ name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DesktopNavigatorProps {
|
function pathToTab(pathname: string): TabName {
|
||||||
unreadCount?: number;
|
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 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);
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
|
||||||
// 检测是否是桌面尺寸
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkDesktop = () => {
|
if (Platform.OS !== 'web') return;
|
||||||
const width = window.innerWidth || document.documentElement.clientWidth;
|
const check = () => {
|
||||||
setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP);
|
const w = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP);
|
||||||
};
|
};
|
||||||
|
check();
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
checkDesktop();
|
const sidebarWidth = isCollapsed
|
||||||
window.addEventListener('resize', checkDesktop);
|
? SIDEBAR_COLLAPSED_WIDTH
|
||||||
setIsReady(true);
|
: isDesktop
|
||||||
|
? SIDEBAR_WIDTH_DESKTOP
|
||||||
|
: SIDEBAR_WIDTH_TABLET;
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', checkDesktop);
|
const currentTab = pathToTab(pathname || '/home');
|
||||||
}, [setIsReady]);
|
|
||||||
|
|
||||||
// 计算侧边栏宽度
|
const handleTabChange = useCallback(
|
||||||
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET);
|
(href: string) => {
|
||||||
|
router.replace(href);
|
||||||
// 处理 Tab 切换
|
},
|
||||||
const handleTabChange = useCallback((tab: TabName) => {
|
[router]
|
||||||
setCurrentTab(tab);
|
);
|
||||||
}, [setCurrentTab]);
|
|
||||||
|
|
||||||
// 渲染当前 Tab 的内容
|
|
||||||
const renderTabContent = () => {
|
|
||||||
switch (currentTab) {
|
|
||||||
case 'HomeTab':
|
|
||||||
return <HomeScreen />;
|
|
||||||
case 'MessageTab':
|
|
||||||
return <MessageListScreen />;
|
|
||||||
case 'ScheduleTab':
|
|
||||||
return <ScheduleScreen />;
|
|
||||||
case 'ProfileTab':
|
|
||||||
return <ProfileScreen />;
|
|
||||||
default:
|
|
||||||
return <HomeScreen />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 侧边栏 */}
|
<SafeAreaView
|
||||||
<SafeAreaView style={[styles.sidebar, { width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
style={[
|
||||||
{/* Logo 区域 */}
|
styles.sidebar,
|
||||||
|
{ width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.sidebarHeader}>
|
<View style={styles.sidebarHeader}>
|
||||||
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <Text style={styles.logoText}>胡萝卜BBS</Text>}
|
||||||
<Text style={styles.logoText}>胡萝卜BBS</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 导航项 */}
|
|
||||||
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive = currentTab === item.name;
|
const isActive = currentTab === item.name;
|
||||||
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -110,35 +94,31 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
|||||||
isActive && styles.sidebarItemActive,
|
isActive && styles.sidebarItemActive,
|
||||||
isCollapsed && styles.sidebarItemCollapsed,
|
isCollapsed && styles.sidebarItemCollapsed,
|
||||||
]}
|
]}
|
||||||
onPress={() => handleTabChange(item.name)}
|
onPress={() => handleTabChange(item.href)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarIconContainer}>
|
<View style={styles.sidebarIconContainer}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={isActive ? item.icon as any : item.iconOutline as any}
|
name={(isActive ? item.icon : item.iconOutline) as any}
|
||||||
size={24}
|
size={24}
|
||||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
{showBadge && (
|
{showBadge ? (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{!isCollapsed && (
|
{!isCollapsed ? (
|
||||||
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>
|
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>{item.label}</Text>
|
||||||
{item.label}
|
) : null}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 折叠按钮 */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
|
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
|
||||||
onPress={toggleCollapse}
|
onPress={() => setIsCollapsed((c) => !c)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
@@ -148,10 +128,8 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
||||||
{/* 主内容区域 */}
|
|
||||||
<View style={styles.mainContent}>
|
<View style={styles.mainContent}>
|
||||||
{renderTabContent()}
|
<AppRouteStack />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
8
src/app-navigation/AppRouteStack.tsx
Normal file
8
src/app-navigation/AppRouteStack.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已登录区内栈:由 app/(app) 下文件系统自动注册子路由
|
||||||
|
*/
|
||||||
|
export function AppRouteStack() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -329,18 +329,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 子评论操作按钮 */}
|
{/* 子评论操作按钮 */}
|
||||||
<View style={styles.subReplyActions}>
|
<View style={styles.subReplyActions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.actionButton}
|
style={styles.subActionButton}
|
||||||
onPress={() => onReplyPress?.(reply)}
|
onPress={() => onReplyPress?.(reply)}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||||
回复
|
回复
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* 删除按钮 - 子评论作者可见 */}
|
{/* 删除按钮 - 子评论作者可见 */}
|
||||||
{isSubReplyAuthor && (
|
{isSubReplyAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.actionButton}
|
style={styles.subActionButton}
|
||||||
onPress={() => handleSubReplyDelete(reply)}
|
onPress={() => handleSubReplyDelete(reply)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
@@ -349,7 +349,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
size={12}
|
size={12}
|
||||||
color={colors.text.hint}
|
color={colors.text.hint}
|
||||||
/>
|
/>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||||
{isDeleting ? '删除中' : '删除'}
|
{isDeleting ? '删除中' : '删除'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -476,21 +476,26 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingVertical: spacing.sm,
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: 'transparent',
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderBottomColor: colors.divider,
|
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
userInfo: {
|
userInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -537,7 +542,9 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: 999,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
floorText: {
|
floorText: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
@@ -546,7 +553,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
commentContent: {
|
commentContent: {
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
@@ -559,17 +566,24 @@ const styles = StyleSheet.create({
|
|||||||
actionButton: {
|
actionButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.sm,
|
||||||
paddingVertical: spacing.xs,
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
actionText: {
|
actionText: {
|
||||||
marginLeft: 2,
|
marginLeft: 4,
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
},
|
},
|
||||||
subRepliesContainer: {
|
subRepliesContainer: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
padding: spacing.sm,
|
padding: spacing.sm,
|
||||||
},
|
},
|
||||||
subReplyItem: {
|
subReplyItem: {
|
||||||
@@ -607,6 +621,16 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
|
subActionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
subActionText: {
|
||||||
|
marginLeft: 2,
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default CommentItem;
|
export default CommentItem;
|
||||||
|
|||||||
@@ -9,23 +9,20 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
interface QRCodeScannerProps {
|
interface QRCodeScannerProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
@@ -52,7 +49,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
const sessionId = extractSessionId(data);
|
const sessionId = extractSessionId(data);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// 跳转到确认页面
|
// 跳转到确认页面
|
||||||
navigation.navigate('QRCodeConfirm', { sessionId });
|
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('无效的二维码', '无法识别该二维码');
|
Alert.alert('无效的二维码', '无法识别该二维码');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,22 +236,22 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
|
|
||||||
{/* 个人信息标签 */}
|
{/* 个人信息标签 */}
|
||||||
<View style={styles.metaInfo}>
|
<View style={styles.metaInfo}>
|
||||||
{user.location && (
|
{user.location?.trim() ? (
|
||||||
<View style={styles.metaTag}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||||
{user.location}
|
{user.location}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
{user.website && (
|
{user.website?.trim() ? (
|
||||||
<View style={styles.metaTag}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||||
{user.website.replace(/^https?:\/\//, '')}
|
{user.website.replace(/^https?:\/\//, '')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
<View style={styles.metaTag}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||||
|
|||||||
54
src/components/common/AppBackButton.tsx
Normal file
54
src/components/common/AppBackButton.tsx
Normal file
@@ -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<ViewStyle>;
|
||||||
|
iconColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
hitSlop?: number;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
icon = 'arrow-left',
|
||||||
|
style,
|
||||||
|
iconColor = colors.text.primary,
|
||||||
|
backgroundColor = colors.background.paper,
|
||||||
|
hitSlop = 8,
|
||||||
|
testID,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.button, { backgroundColor }, style]}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
hitSlop={hitSlop}
|
||||||
|
testID={testID}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AppBackButton;
|
||||||
@@ -87,6 +87,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isClosingRef = useRef(false);
|
const isClosingRef = useRef(false);
|
||||||
const currentIndexRef = useRef(initialIndex);
|
const currentIndexRef = useRef(initialIndex);
|
||||||
|
const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
currentIndexRef.current = currentIndex;
|
currentIndexRef.current = currentIndex;
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
@@ -173,6 +174,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
[validImages.length, onIndexChange]
|
[validImages.length, onIndexChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateIndexFromSwipe = useCallback(
|
||||||
|
(newIndex: number, direction: -1 | 1) => {
|
||||||
|
pendingSwipeDirectionRef.current = direction;
|
||||||
|
updateIndex(newIndex);
|
||||||
|
},
|
||||||
|
[updateIndex]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleControls = useCallback(() => {
|
const toggleControls = useCallback(() => {
|
||||||
setShowControls(prev => !prev);
|
setShowControls(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -189,18 +198,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
}, 120);
|
}, 120);
|
||||||
}, [onClose]);
|
}, [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') => {
|
const showToast = useCallback((type: 'success' | 'error') => {
|
||||||
setSaveToast(type);
|
setSaveToast(type);
|
||||||
@@ -269,6 +266,38 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
// 滑动切换图片相关状态
|
// 滑动切换图片相关状态
|
||||||
const swipeTranslateX = useSharedValue(0);
|
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()
|
const panGesture = Gesture.Pan()
|
||||||
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突
|
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突
|
||||||
@@ -309,14 +338,12 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
if (shouldGoNext && currentIndex < validImages.length - 1) {
|
if (shouldGoNext && currentIndex < validImages.length - 1) {
|
||||||
// 向左滑动,显示下一张
|
// 向左滑动,显示下一张
|
||||||
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
|
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
|
||||||
runOnJS(updateIndex)(currentIndex + 1);
|
runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1);
|
||||||
swipeTranslateX.value = 0;
|
|
||||||
});
|
});
|
||||||
} else if (shouldGoPrev && currentIndex > 0) {
|
} else if (shouldGoPrev && currentIndex > 0) {
|
||||||
// 向右滑动,显示上一张
|
// 向右滑动,显示上一张
|
||||||
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
|
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
|
||||||
runOnJS(updateIndex)(currentIndex - 1);
|
runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1);
|
||||||
swipeTranslateX.value = 0;
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 回弹到原位
|
// 回弹到原位
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer';
|
|||||||
export { default as ResponsiveGrid } from './ResponsiveGrid';
|
export { default as ResponsiveGrid } from './ResponsiveGrid';
|
||||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||||
|
export { default as AppBackButton } from './AppBackButton';
|
||||||
|
|
||||||
// 图片相关组件
|
// 图片相关组件
|
||||||
export { default as SmartImage } from './SmartImage';
|
export { default as SmartImage } from './SmartImage';
|
||||||
|
|||||||
@@ -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<TabName>('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;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* 导航服务层
|
|
||||||
* 提供全局导航功能,解耦组件与导航状态的直接依赖
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NavigationContainerRef, Route } from '@react-navigation/native';
|
|
||||||
|
|
||||||
class NavigationService {
|
|
||||||
private navigationRef: NavigationContainerRef<any> | null = null;
|
|
||||||
private isReady: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置导航引用
|
|
||||||
*/
|
|
||||||
setNavigationRef(ref: NavigationContainerRef<any> | 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<string> | 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();
|
|
||||||
@@ -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<any> | null): void;
|
|
||||||
isNavigationReady(): boolean;
|
|
||||||
navigate(routeName: string, params?: any): void;
|
|
||||||
goBack(): void;
|
|
||||||
getCurrentRoute(): Route<string> | 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,
|
|
||||||
};
|
|
||||||
@@ -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<AuthStackParamList>();
|
|
||||||
|
|
||||||
export function AuthNavigator() {
|
|
||||||
return (
|
|
||||||
<AuthStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AuthStack.Screen name="Login" component={LoginScreen} />
|
|
||||||
<AuthStack.Screen name="Register" component={RegisterScreen} />
|
|
||||||
<AuthStack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
|
|
||||||
</AuthStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<HomeStackParamList>();
|
|
||||||
|
|
||||||
export function HomeNavigator() {
|
|
||||||
return (
|
|
||||||
<HomeStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HomeStack.Screen
|
|
||||||
name="Home"
|
|
||||||
component={HomeScreen}
|
|
||||||
options={{
|
|
||||||
title: '首页',
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<HomeStack.Screen
|
|
||||||
name="Search"
|
|
||||||
component={SearchScreen}
|
|
||||||
options={{
|
|
||||||
title: '搜索',
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HomeStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<RootStackParamList> = {
|
|
||||||
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 (
|
|
||||||
<RootNavigator
|
|
||||||
isAuthenticated={isAuthenticated}
|
|
||||||
isInitializing={isInitializing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出 linking 配置供外部使用
|
|
||||||
export { linking };
|
|
||||||
@@ -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<MessageStackParamList>();
|
|
||||||
|
|
||||||
export function MessageNavigator() {
|
|
||||||
return (
|
|
||||||
<MessageStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="MessageList"
|
|
||||||
component={MessageListScreen}
|
|
||||||
options={{
|
|
||||||
title: '消息',
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="Notifications"
|
|
||||||
component={NotificationsScreen}
|
|
||||||
options={{
|
|
||||||
title: '通知',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="PrivateChatInfo"
|
|
||||||
component={PrivateChatInfoScreen}
|
|
||||||
options={{
|
|
||||||
title: '聊天信息',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MessageStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<HomeStackParamList>();
|
|
||||||
const MessageStack = createNativeStackNavigator<MessageStackParamList>();
|
|
||||||
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
|
|
||||||
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
|
|
||||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
|
||||||
|
|
||||||
// ==================== 常量 ====================
|
|
||||||
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 (
|
|
||||||
<HomeStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HomeStack.Screen
|
|
||||||
name="Home"
|
|
||||||
component={HomeScreen}
|
|
||||||
options={{
|
|
||||||
title: '首页',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<HomeStack.Screen
|
|
||||||
name="Search"
|
|
||||||
component={SearchScreen}
|
|
||||||
options={{
|
|
||||||
title: '搜索',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<HomeStack.Screen
|
|
||||||
name="CreatePost"
|
|
||||||
component={CreatePostScreen}
|
|
||||||
options={{
|
|
||||||
title: '发帖',
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'modal',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HomeStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageStackNavigatorComponent() {
|
|
||||||
return (
|
|
||||||
<MessageStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="MessageList"
|
|
||||||
component={MessageListScreen}
|
|
||||||
options={{
|
|
||||||
title: '消息',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="Notifications"
|
|
||||||
component={NotificationsScreen}
|
|
||||||
options={{
|
|
||||||
title: '通知',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MessageStack.Screen
|
|
||||||
name="PrivateChatInfo"
|
|
||||||
component={PrivateChatInfoScreen}
|
|
||||||
options={{
|
|
||||||
title: '聊天信息',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MessageStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScheduleStackNavigatorComponent() {
|
|
||||||
return (
|
|
||||||
<ScheduleStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="Schedule"
|
|
||||||
component={ScheduleScreen}
|
|
||||||
options={{
|
|
||||||
title: '课表',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="CourseDetail"
|
|
||||||
component={CourseDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'transparentModal',
|
|
||||||
animation: 'fade',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScheduleStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileStackNavigatorComponent() {
|
|
||||||
return (
|
|
||||||
<ProfileStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Profile"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Settings"
|
|
||||||
component={SettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '设置',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="EditProfile"
|
|
||||||
component={EditProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '编辑资料',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="AccountSecurity"
|
|
||||||
component={AccountSecurityScreen}
|
|
||||||
options={{
|
|
||||||
title: '账号安全',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="MyPosts"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '我的帖子',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Bookmarks"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '收藏',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="NotificationSettings"
|
|
||||||
component={NotificationSettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '通知设置',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="BlockedUsers"
|
|
||||||
component={BlockedUsersScreen}
|
|
||||||
options={{
|
|
||||||
title: '黑名单',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ProfileStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 主组件: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 (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染错误状态(带重试按钮)
|
|
||||||
if (hasError) {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="alert-circle-outline"
|
|
||||||
size={48}
|
|
||||||
color={colors.error.main}
|
|
||||||
/>
|
|
||||||
<ActivityIndicator
|
|
||||||
size="small"
|
|
||||||
color={colors.primary.main}
|
|
||||||
style={styles.retryIndicator}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染 Tab Navigator
|
|
||||||
return (
|
|
||||||
<Tab.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: colors.primary.main,
|
|
||||||
tabBarInactiveTintColor: colors.text.secondary,
|
|
||||||
tabBarHideOnKeyboard: true,
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: `${colors.divider}88`,
|
|
||||||
borderRadius: 24,
|
|
||||||
marginHorizontal: 14,
|
|
||||||
marginBottom: MOBILE_TAB_FLOATING_MARGIN + insets.bottom,
|
|
||||||
height: 64,
|
|
||||||
paddingBottom: 6,
|
|
||||||
paddingTop: 8,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
position: 'absolute',
|
|
||||||
...shadows.lg,
|
|
||||||
},
|
|
||||||
tabBarItemStyle: {
|
|
||||||
borderRadius: 18,
|
|
||||||
paddingVertical: 1,
|
|
||||||
marginHorizontal: 2,
|
|
||||||
},
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: -2,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
|
||||||
tabBarBadgeStyle: {
|
|
||||||
backgroundColor: colors.error.main,
|
|
||||||
color: colors.primary.contrast,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '700',
|
|
||||||
top: 4,
|
|
||||||
},
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="HomeTab"
|
|
||||||
component={HomeStackNavigatorComponent}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '首页',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabIconContainer,
|
|
||||||
focused && styles.tabIconActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'home' : 'home-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="MessageTab"
|
|
||||||
component={MessageStackNavigatorComponent}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '消息',
|
|
||||||
tabBarBadge:
|
|
||||||
messageUnreadCount > 0
|
|
||||||
? messageUnreadCount > 99
|
|
||||||
? '99+'
|
|
||||||
: messageUnreadCount
|
|
||||||
: undefined,
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabIconContainer,
|
|
||||||
focused && styles.tabIconActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'message-text' : 'message-text-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="ScheduleTab"
|
|
||||||
component={ScheduleStackNavigatorComponent}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '课表',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabIconContainer,
|
|
||||||
focused && styles.tabIconActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'calendar-today' : 'calendar-today'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="ProfileTab"
|
|
||||||
component={ProfileStackNavigatorComponent}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '我的',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabIconContainer,
|
|
||||||
focused && styles.tabIconActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'account' : 'account-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 样式 ====================
|
|
||||||
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 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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<ProfileStackParamList>();
|
|
||||||
|
|
||||||
export function ProfileNavigator() {
|
|
||||||
return (
|
|
||||||
<ProfileStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Profile"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Settings"
|
|
||||||
component={SettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '设置',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="EditProfile"
|
|
||||||
component={EditProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '编辑资料',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="AccountSecurity"
|
|
||||||
component={AccountSecurityScreen}
|
|
||||||
options={{
|
|
||||||
title: '账号安全',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="MyPosts"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '我的帖子',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Bookmarks"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '收藏',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="NotificationSettings"
|
|
||||||
component={NotificationSettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '通知设置',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="BlockedUsers"
|
|
||||||
component={BlockedUsersScreen}
|
|
||||||
options={{
|
|
||||||
title: '黑名单',
|
|
||||||
headerBackTitle: '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ProfileStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<RootStackParamList>();
|
|
||||||
|
|
||||||
interface RootNavigatorProps {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isInitializing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未认证时可访问的屏幕 - 返回数组而不是 JSX
|
|
||||||
const getPublicScreens = () => [
|
|
||||||
<RootStack.Screen
|
|
||||||
key="PostDetail"
|
|
||||||
name="PostDetail"
|
|
||||||
component={PostDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="UserProfile"
|
|
||||||
name="UserProfile"
|
|
||||||
component={UserScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '用户主页',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 认证后可访问的屏幕 - 返回数组而不是 JSX
|
|
||||||
const getAuthenticatedScreens = () => [
|
|
||||||
<RootStack.Screen
|
|
||||||
key="PostDetail"
|
|
||||||
name="PostDetail"
|
|
||||||
component={PostDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="UserProfile"
|
|
||||||
name="UserProfile"
|
|
||||||
component={UserScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '用户主页',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="CreatePost"
|
|
||||||
name="CreatePost"
|
|
||||||
component={CreatePostScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '发布帖子',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
presentation: 'modal',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="Chat"
|
|
||||||
name="Chat"
|
|
||||||
component={ChatScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="FollowList"
|
|
||||||
name="FollowList"
|
|
||||||
component={FollowListScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '关注列表',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="CreateGroup"
|
|
||||||
name="CreateGroup"
|
|
||||||
component={CreateGroupScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '创建群聊',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
presentation: 'modal',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="JoinGroup"
|
|
||||||
name="JoinGroup"
|
|
||||||
component={JoinGroupScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '搜索群聊',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="GroupInfo"
|
|
||||||
name="GroupInfo"
|
|
||||||
component={GroupInfoScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '群信息',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="GroupMembers"
|
|
||||||
name="GroupMembers"
|
|
||||||
component={GroupMembersScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '群成员',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="GroupRequestDetail"
|
|
||||||
name="GroupRequestDetail"
|
|
||||||
component={GroupRequestDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '入群审批',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="GroupInviteDetail"
|
|
||||||
name="GroupInviteDetail"
|
|
||||||
component={GroupInviteDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '群聊邀请',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="PrivateChatInfo"
|
|
||||||
name="PrivateChatInfo"
|
|
||||||
component={PrivateChatInfoScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '聊天信息',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
<RootStack.Screen
|
|
||||||
key="QRCodeConfirm"
|
|
||||||
name="QRCodeConfirm"
|
|
||||||
component={QRCodeConfirmScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'modal',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
|
|
||||||
const { isMobile } = useResponsive();
|
|
||||||
const unreadCount = useTotalUnreadCount();
|
|
||||||
|
|
||||||
// 设置导航引用
|
|
||||||
const setNavigationRef = (ref: any) => {
|
|
||||||
navigationService.setNavigationRef(ref);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载中显示
|
|
||||||
if (isInitializing) {
|
|
||||||
return (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationContainer ref={setNavigationRef}>
|
|
||||||
<RootStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<RootStack.Screen
|
|
||||||
name="Main"
|
|
||||||
navigationKey={isMobile ? 'main-mobile' : 'main-desktop'}
|
|
||||||
>
|
|
||||||
{() =>
|
|
||||||
isMobile ? (
|
|
||||||
<SimpleMobileTabNavigator />
|
|
||||||
) : (
|
|
||||||
<DesktopNavigator unreadCount={unreadCount} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</RootStack.Screen>
|
|
||||||
{getAuthenticatedScreens()}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RootStack.Screen
|
|
||||||
name="Auth"
|
|
||||||
component={AuthNavigator}
|
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
|
||||||
{getPublicScreens()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</RootStack.Navigator>
|
|
||||||
</NavigationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
loadingContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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<ScheduleStackParamList>();
|
|
||||||
|
|
||||||
export function ScheduleNavigator() {
|
|
||||||
return (
|
|
||||||
<ScheduleStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="Schedule"
|
|
||||||
component={ScheduleScreen}
|
|
||||||
options={{
|
|
||||||
title: '课表',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="CourseDetail"
|
|
||||||
component={CourseDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'transparentModal',
|
|
||||||
animation: 'fade',
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScheduleStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<ScheduleStackParamList>();
|
|
||||||
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
|
|
||||||
|
|
||||||
// ==================== 常量 ====================
|
|
||||||
const TAB_BAR_HEIGHT = 64;
|
|
||||||
const TAB_BAR_MARGIN = 14;
|
|
||||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
|
||||||
|
|
||||||
// ==================== 组件 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule Stack Navigator 组件
|
|
||||||
*/
|
|
||||||
function ScheduleStackNavigatorComponent() {
|
|
||||||
return (
|
|
||||||
<ScheduleStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="Schedule"
|
|
||||||
component={ScheduleScreen}
|
|
||||||
options={{
|
|
||||||
title: '课表',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ScheduleStack.Screen
|
|
||||||
name="CourseDetail"
|
|
||||||
component={CourseDetailScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'transparentModal',
|
|
||||||
animation: 'fade',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScheduleStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Profile Stack Navigator 组件
|
|
||||||
*/
|
|
||||||
function ProfileStackNavigatorComponent() {
|
|
||||||
return (
|
|
||||||
<ProfileStack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
headerBackTitle: '',
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Profile"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Settings"
|
|
||||||
component={SettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '设置',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="EditProfile"
|
|
||||||
component={EditProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '编辑资料',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="AccountSecurity"
|
|
||||||
component={AccountSecurityScreen}
|
|
||||||
options={{
|
|
||||||
title: '账号安全',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="MyPosts"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '我的帖子',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="Bookmarks"
|
|
||||||
component={ProfileScreen}
|
|
||||||
options={{
|
|
||||||
title: '收藏',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="NotificationSettings"
|
|
||||||
component={NotificationSettingsScreen}
|
|
||||||
options={{
|
|
||||||
title: '通知设置',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProfileStack.Screen
|
|
||||||
name="BlockedUsers"
|
|
||||||
component={BlockedUsersScreen}
|
|
||||||
options={{
|
|
||||||
title: '黑名单',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ProfileStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主组件:SimpleMobileTabNavigator
|
|
||||||
*/
|
|
||||||
export function SimpleMobileTabNavigator() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const messageUnreadCount = useTotalUnreadCount();
|
|
||||||
const [activeTab, setActiveTab] = useState<TabName>('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 (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? 'home' : 'home-outline'}
|
|
||||||
size={iconSize}
|
|
||||||
color={iconColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'MessageTab':
|
|
||||||
return (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? 'message-text' : 'message-text-outline'}
|
|
||||||
size={iconSize}
|
|
||||||
color={iconColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'ScheduleTab':
|
|
||||||
return (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? 'calendar-today' : 'calendar-today'}
|
|
||||||
size={iconSize}
|
|
||||||
color={iconColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'ProfileTab':
|
|
||||||
return (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? 'account' : 'account-outline'}
|
|
||||||
size={iconSize}
|
|
||||||
color={iconColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染 Tab Bar 标签
|
|
||||||
const renderTabLabel = (tabName: TabName, isActive: boolean) => {
|
|
||||||
const labels: Record<TabName, string> = {
|
|
||||||
HomeTab: '首页',
|
|
||||||
MessageTab: '消息',
|
|
||||||
ScheduleTab: '课表',
|
|
||||||
ProfileTab: '我的',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.tabLabel,
|
|
||||||
isActive && styles.tabLabelActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{labels[tabName]}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.content}>
|
|
||||||
{plainTabEverShown.HomeTab && (
|
|
||||||
<View
|
|
||||||
key="HomeTab"
|
|
||||||
style={[
|
|
||||||
styles.tabScreen,
|
|
||||||
activeTab !== 'HomeTab' && styles.tabScreenHidden,
|
|
||||||
]}
|
|
||||||
pointerEvents={activeTab === 'HomeTab' ? 'auto' : 'none'}
|
|
||||||
>
|
|
||||||
<HomeScreen />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{plainTabEverShown.MessageTab && (
|
|
||||||
<View
|
|
||||||
key="MessageTab"
|
|
||||||
style={[
|
|
||||||
styles.tabScreen,
|
|
||||||
activeTab !== 'MessageTab' && styles.tabScreenHidden,
|
|
||||||
]}
|
|
||||||
pointerEvents={activeTab === 'MessageTab' ? 'auto' : 'none'}
|
|
||||||
>
|
|
||||||
<MessageListScreen />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{activeTab === 'ScheduleTab' && (
|
|
||||||
<View key="ScheduleTab" style={styles.tabScreen}>
|
|
||||||
<ScheduleStackNavigatorComponent />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{activeTab === 'ProfileTab' && (
|
|
||||||
<View key="ProfileTab" style={styles.tabScreen}>
|
|
||||||
<ProfileStackNavigatorComponent />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Tab Bar */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabBar,
|
|
||||||
{
|
|
||||||
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map(
|
|
||||||
(tabName) => {
|
|
||||||
const isActive = activeTab === tabName;
|
|
||||||
const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={tabName}
|
|
||||||
style={[
|
|
||||||
styles.tabItem,
|
|
||||||
isActive && styles.tabItemActive,
|
|
||||||
]}
|
|
||||||
onPress={() => handleTabPress(tabName)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.tabIconContainer}>
|
|
||||||
{renderTabIcon(tabName, isActive)}
|
|
||||||
{showBadge && (
|
|
||||||
<View style={styles.badge}>
|
|
||||||
<Text style={styles.badgeText}>
|
|
||||||
{messageUnreadCount > 99 ? '99+' : messageUnreadCount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{renderTabLabel(tabName, isActive)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 样式 ====================
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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<MainTabParamList>();
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
|
||||||
|
|
||||||
interface TabNavigatorProps {
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabNavigator({ unreadCount }: TabNavigatorProps) {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tab.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: colors.primary.main,
|
|
||||||
tabBarInactiveTintColor: colors.text.secondary,
|
|
||||||
tabBarHideOnKeyboard: true,
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: `${colors.divider}88`,
|
|
||||||
borderRadius: 24,
|
|
||||||
marginHorizontal: 14,
|
|
||||||
marginBottom: MOBILE_TAB_FLOATING_MARGIN + insets.bottom,
|
|
||||||
height: 64,
|
|
||||||
paddingBottom: 6,
|
|
||||||
paddingTop: 8,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
position: 'absolute',
|
|
||||||
...shadows.lg,
|
|
||||||
},
|
|
||||||
tabBarItemStyle: {
|
|
||||||
borderRadius: 18,
|
|
||||||
paddingVertical: 1,
|
|
||||||
marginHorizontal: 2,
|
|
||||||
},
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: -2,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
|
||||||
tabBarBadgeStyle: {
|
|
||||||
backgroundColor: colors.error.main,
|
|
||||||
color: colors.primary.contrast,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '700',
|
|
||||||
top: 4,
|
|
||||||
},
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="HomeTab"
|
|
||||||
component={HomeNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '首页',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'home' : 'home-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="MessageTab"
|
|
||||||
component={MessageNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '消息',
|
|
||||||
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined,
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'message-text' : 'message-text-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="ScheduleTab"
|
|
||||||
component={ScheduleNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '课表',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'calendar-today' : 'calendar-today'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="ProfileTab"
|
|
||||||
component={ProfileNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarLabel: '我的',
|
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
|
||||||
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={focused ? 'account' : 'account-outline'}
|
|
||||||
size={focused ? 26 : 24}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
153
src/navigation/hrefs.ts
Normal file
153
src/navigation/hrefs.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
@@ -1,29 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 导出所有导航组件
|
* 导航路径工具(运行时导航请使用 expo-router)
|
||||||
*/
|
*/
|
||||||
|
export * from './hrefs';
|
||||||
// 类型
|
|
||||||
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';
|
|
||||||
|
|||||||
11
src/navigation/paramUtils.ts
Normal file
11
src/navigation/paramUtils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HomeStackParamList>;
|
|
||||||
MessageTab: NavigatorScreenParams<MessageStackParamList>;
|
|
||||||
ScheduleTab: NavigatorScreenParams<ScheduleStackParamList>;
|
|
||||||
ProfileTab: NavigatorScreenParams<ProfileStackParamList>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 首页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<MainTabParamList>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -11,19 +11,15 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
type ForgotPasswordNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
|
||||||
|
|
||||||
export const ForgotPasswordScreen: React.FC = () => {
|
export const ForgotPasswordScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<ForgotPasswordNavigationProp>();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
@@ -95,7 +91,7 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
if (ok) {
|
if (ok) {
|
||||||
showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' });
|
showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' });
|
||||||
navigation.goBack();
|
router.back();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||||
@@ -192,7 +188,7 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
<Text style={styles.backButtonText}>返回登录</Text>
|
<Text style={styles.backButtonText}>返回登录</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -22,24 +22,22 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
|
||||||
|
|
||||||
// 分栏布局断点
|
// 分栏布局断点
|
||||||
const SPLIT_BREAKPOINT = 768;
|
const SPLIT_BREAKPOINT = 768;
|
||||||
|
|
||||||
export const LoginScreen: React.FC = () => {
|
export const LoginScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<LoginNavigationProp>();
|
const router = useRouter();
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const storeError = useAuthStore((state) => state.error);
|
const storeError = useAuthStore((state) => state.error);
|
||||||
const setStoreError = useAuthStore((state) => state.setError);
|
const setStoreError = useAuthStore((state) => state.setError);
|
||||||
|
|
||||||
@@ -131,6 +129,12 @@ export const LoginScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [storeError]);
|
}, [storeError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace(hrefs.hrefHome());
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
setStoreError(null);
|
setStoreError(null);
|
||||||
@@ -158,7 +162,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 跳转到注册页
|
// 跳转到注册页
|
||||||
const handleGoToRegister = () => {
|
const handleGoToRegister = () => {
|
||||||
navigation.navigate('Register' as any);
|
router.push(hrefs.hrefAuthRegister());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||||
@@ -297,7 +301,10 @@ export const LoginScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 忘记密码 */}
|
{/* 忘记密码 */}
|
||||||
<TouchableOpacity style={styles.forgotPassword} onPress={() => navigation.navigate('ForgotPassword' as any)}>
|
<TouchableOpacity
|
||||||
|
style={styles.forgotPassword}
|
||||||
|
onPress={() => router.push(hrefs.hrefAuthForgot())}
|
||||||
|
>
|
||||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|||||||
@@ -10,28 +10,24 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import { qrcodeApi } from '../../services/authService';
|
import { qrcodeApi } from '../../services/authService';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
|
import { AppBackButton } from '../../components/common';
|
||||||
type QRCodeConfirmRouteProp = RouteProp<RootStackParamList, 'QRCodeConfirm'>;
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
export const QRCodeConfirmScreen: React.FC = () => {
|
export const QRCodeConfirmScreen: React.FC = () => {
|
||||||
const route = useRoute<QRCodeConfirmRouteProp>();
|
const router = useRouter();
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>();
|
||||||
const { sessionId } = route.params;
|
const sessionId = sessionIdParam || '';
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
|
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 扫码
|
if (!sessionId) return;
|
||||||
handleScan();
|
void handleScan();
|
||||||
}, []);
|
}, [sessionId]);
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +38,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
|||||||
const errorMsg = err.response?.data?.message || '扫码失败';
|
const errorMsg = err.response?.data?.message || '扫码失败';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
Alert.alert('扫码失败', errorMsg, [
|
Alert.alert('扫码失败', errorMsg, [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() }
|
{ text: '确定', onPress: () => router.back() }
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -54,7 +50,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
await qrcodeApi.confirm(sessionId);
|
await qrcodeApi.confirm(sessionId);
|
||||||
Alert.alert('登录成功', '网页端已登录', [
|
Alert.alert('登录成功', '网页端已登录', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() }
|
{ text: '确定', onPress: () => router.back() }
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMsg = err.response?.data?.message || '确认登录失败';
|
const errorMsg = err.response?.data?.message || '确认登录失败';
|
||||||
@@ -70,7 +66,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 忽略取消错误
|
// 忽略取消错误
|
||||||
}
|
}
|
||||||
navigation.goBack();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && !userInfo) {
|
if (loading && !userInfo) {
|
||||||
@@ -90,7 +86,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
|||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
|
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
<Text style={styles.backButtonText}>返回</Text>
|
<Text style={styles.backButtonText}>返回</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -101,9 +97,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
<AppBackButton onPress={handleCancel} icon="close" style={styles.closeButton} iconColor="#666" />
|
||||||
<MaterialCommunityIcons name="close" size={24} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>确认登录</Text>
|
<Text style={styles.headerTitle}>确认登录</Text>
|
||||||
<View style={styles.placeholder} />
|
<View style={styles.placeholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -22,25 +22,23 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
|
||||||
|
|
||||||
// 分栏布局断点
|
// 分栏布局断点
|
||||||
const SPLIT_BREAKPOINT = 768;
|
const SPLIT_BREAKPOINT = 768;
|
||||||
|
|
||||||
export const RegisterScreen: React.FC = () => {
|
export const RegisterScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<RegisterNavigationProp>();
|
const router = useRouter();
|
||||||
const register = useAuthStore((state) => state.register);
|
const register = useAuthStore((state) => state.register);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
const { isLandscape, width } = useResponsive();
|
const { isLandscape, width } = useResponsive();
|
||||||
@@ -48,6 +46,12 @@ export const RegisterScreen: React.FC = () => {
|
|||||||
// 是否使用分栏布局
|
// 是否使用分栏布局
|
||||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace(hrefs.hrefHome());
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [nickname, setNickname] = useState('');
|
const [nickname, setNickname] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -238,7 +242,7 @@ export const RegisterScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 跳转到登录页
|
// 跳转到登录页
|
||||||
const handleGoToLogin = () => {
|
const handleGoToLogin = () => {
|
||||||
navigation.navigate('Login' as any);
|
router.push(hrefs.hrefAuthLogin());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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 { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
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 { postService, showPrompt, voteService } from '../../services';
|
||||||
import { ApiError } from '../../services/api';
|
import { ApiError } from '../../services/api';
|
||||||
import { uploadService } from '../../services/uploadService';
|
import { uploadService } from '../../services/uploadService';
|
||||||
import VoteEditor from '../../components/business/VoteEditor';
|
import VoteEditor from '../../components/business/VoteEditor';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
// Props 接口
|
// Props 接口
|
||||||
interface CreatePostScreenProps {
|
interface CreatePostScreenProps {
|
||||||
@@ -80,10 +80,11 @@ const getPublishErrorMessage = (error: unknown): string => {
|
|||||||
|
|
||||||
export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const router = useRouter();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const route = useRoute<RouteProp<RootStackParamList, 'CreatePost'>>();
|
const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>();
|
||||||
const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId;
|
const isEditMode = mode === 'edit' && !!postIdParam;
|
||||||
const editPostID = route.params?.postId || '';
|
const editPostID = postIdParam || '';
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
const { isWideScreen, width } = useResponsive();
|
const { isWideScreen, width } = useResponsive();
|
||||||
@@ -135,12 +136,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
|
headerShown: true,
|
||||||
title: isEditMode ? '编辑帖子' : '发布帖子',
|
title: isEditMode ? '编辑帖子' : '发布帖子',
|
||||||
// 当作为 Modal 使用时,显示关闭按钮
|
// 当作为 Modal 使用时,显示关闭按钮
|
||||||
headerLeft: onClose ? () => (
|
headerLeft: onClose ? () => (
|
||||||
<TouchableOpacity onPress={onClose} style={{ padding: 8 }}>
|
<AppBackButton onPress={onClose} icon="close" />
|
||||||
<MaterialCommunityIcons name="close" size={24} color={colors.text.primary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : undefined,
|
) : undefined,
|
||||||
});
|
});
|
||||||
}, [navigation, isEditMode, onClose]);
|
}, [navigation, isEditMode, onClose]);
|
||||||
@@ -156,7 +156,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
const existingPost = await postService.getPost(editPostID);
|
const existingPost = await postService.getPost(editPostID);
|
||||||
if (!existingPost) {
|
if (!existingPost) {
|
||||||
Alert.alert('提示', '帖子不存在或已被删除');
|
Alert.alert('提示', '帖子不存在或已被删除');
|
||||||
navigation.goBack();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTitle(existingPost.title || '');
|
setTitle(existingPost.title || '');
|
||||||
@@ -165,14 +165,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载待编辑帖子失败:', error);
|
console.error('加载待编辑帖子失败:', error);
|
||||||
Alert.alert('错误', '加载帖子失败,请稍后重试');
|
Alert.alert('错误', '加载帖子失败,请稍后重试');
|
||||||
navigation.goBack();
|
router.back();
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPost(false);
|
setLoadingPost(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPostForEdit();
|
loadPostForEdit();
|
||||||
}, [isEditMode, editPostID, navigation]);
|
}, [isEditMode, editPostID, router]);
|
||||||
|
|
||||||
// 选择图片
|
// 选择图片
|
||||||
const handlePickImage = async () => {
|
const handlePickImage = async () => {
|
||||||
@@ -381,10 +381,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
message: '投票帖已提交,内容审核中,稍后展示',
|
message: '投票帖已提交,内容审核中,稍后展示',
|
||||||
duration: 2600,
|
duration: 2600,
|
||||||
});
|
});
|
||||||
navigation.reset({
|
router.replace(hrefs.hrefHome());
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Main' }],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (isEditMode && editPostID) {
|
if (isEditMode && editPostID) {
|
||||||
const updated = await postService.updatePost(editPostID, {
|
const updated = await postService.updatePost(editPostID, {
|
||||||
@@ -401,10 +398,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
message: '帖子内容已更新',
|
message: '帖子内容已更新',
|
||||||
duration: 2200,
|
duration: 2200,
|
||||||
});
|
});
|
||||||
navigation.reset({
|
router.replace(hrefs.hrefHome());
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Main' }],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// 创建普通帖子
|
// 创建普通帖子
|
||||||
await postService.createPost({
|
await postService.createPost({
|
||||||
@@ -418,10 +412,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
message: '帖子已提交,内容审核中,稍后展示',
|
message: '帖子已提交,内容审核中,稍后展示',
|
||||||
duration: 2600,
|
duration: 2600,
|
||||||
});
|
});
|
||||||
navigation.reset({
|
router.replace(hrefs.hrefHome());
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Main' }],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
@@ -31,15 +30,12 @@ import { postService } from '../../services';
|
|||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
import type { PostCardAction } from '../../components/business/PostCard';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
|
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
|
||||||
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||||
import { SearchScreen } from './SearchScreen';
|
import { SearchScreen } from './SearchScreen';
|
||||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const TABS = ['最新', '关注', '热门'];
|
const TABS = ['最新', '关注', '热门'];
|
||||||
const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire'];
|
const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire'];
|
||||||
@@ -54,7 +50,7 @@ type ViewMode = 'list' | 'grid';
|
|||||||
type PostType = 'follow' | 'hot' | 'latest';
|
type PostType = 'follow' | 'hot' | 'latest';
|
||||||
|
|
||||||
export const HomeScreen: React.FC = () => {
|
export const HomeScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { posts: storePosts } = useUserStore();
|
const { posts: storePosts } = useUserStore();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
@@ -302,12 +298,12 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 跳转到帖子详情
|
// 跳转到帖子详情
|
||||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||||
navigationService.navigate('PostDetail', { postId, scrollToComments });
|
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转到用户主页
|
// 跳转到用户主页
|
||||||
const handleUserPress = (userId: string) => {
|
const handleUserPress = (userId: string) => {
|
||||||
navigationService.navigate('UserProfile', { userId });
|
router.push(hrefs.hrefUserProfile(userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点赞帖子
|
// 点赞帖子
|
||||||
@@ -563,10 +559,12 @@ export const HomeScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<View style={styles.waterfallColumnsRow}>
|
||||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
||||||
{/* 加载更多指示器 */}
|
</View>
|
||||||
{isLoading && displayPosts.length > 0 && (
|
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */}
|
||||||
<View style={styles.loadingMore}>
|
{isLoadingMore && displayPosts.length > 0 && (
|
||||||
|
<View style={styles.loadingMoreFooter}>
|
||||||
<Loading size="sm" />
|
<Loading size="sm" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -808,8 +806,12 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
waterfallContainer: {
|
waterfallContainer: {
|
||||||
flexDirection: 'row',
|
width: '100%',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
waterfallColumnsRow: {
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
waterfallColumn: {
|
waterfallColumn: {
|
||||||
@@ -844,7 +846,7 @@ const styles = StyleSheet.create({
|
|||||||
height: 72,
|
height: 72,
|
||||||
borderRadius: 36,
|
borderRadius: 36,
|
||||||
},
|
},
|
||||||
loadingMore: {
|
loadingMoreFooter: {
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import {
|
|||||||
Clipboard,
|
Clipboard,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
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 { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard } from '../../components/business';
|
import { CommentItem, VoteCard } from '../../components/business';
|
||||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
|
|
||||||
type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
|
|
||||||
|
|
||||||
export const PostDetailScreen: React.FC = () => {
|
export const PostDetailScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation();
|
||||||
const route = useRoute<PostDetailRouteProp>();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const postId = route.params?.postId || '';
|
const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{
|
||||||
const shouldScrollToComments = route.params?.scrollToComments || false;
|
postId?: string;
|
||||||
|
scrollToComments?: string;
|
||||||
|
}>();
|
||||||
|
const postId = postIdParam || '';
|
||||||
|
const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true';
|
||||||
|
|
||||||
// 使用响应式 hook
|
// 使用响应式 hook
|
||||||
const {
|
const {
|
||||||
@@ -282,23 +282,18 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
const author = post.author;
|
const author = post.author;
|
||||||
const handleBackPress = () => {
|
const handleBackPress = () => {
|
||||||
if (navigation.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
navigation.goBack();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigation.navigate('Main', {
|
router.replace(hrefs.hrefHome());
|
||||||
screen: 'HomeTab',
|
|
||||||
params: { screen: 'Home', params: undefined },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerBackVisible: false,
|
headerBackVisible: false,
|
||||||
headerTitleAlign: 'left',
|
headerTitleAlign: 'left',
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity onPress={handleBackPress} style={styles.headerBackButton} hitSlop={8}>
|
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
),
|
||||||
headerTitle: () => (
|
headerTitle: () => (
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
@@ -641,7 +636,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
Alert.alert('删除成功', '帖子已删除', [
|
Alert.alert('删除成功', '帖子已删除', [
|
||||||
{
|
{
|
||||||
text: '确定',
|
text: '确定',
|
||||||
onPress: () => navigation.goBack(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -654,15 +649,12 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [post, isDeleting, currentUser?.id, navigation]);
|
}, [post, isDeleting, currentUser?.id, router]);
|
||||||
|
|
||||||
const handleEditPost = useCallback(() => {
|
const handleEditPost = useCallback(() => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
navigation.navigate('CreatePost', {
|
router.push(hrefs.hrefCreatePost('edit', post.id));
|
||||||
mode: 'edit',
|
}, [router, post]);
|
||||||
postId: post.id,
|
|
||||||
});
|
|
||||||
}, [navigation, post]);
|
|
||||||
|
|
||||||
// 点击图片查看大图
|
// 点击图片查看大图
|
||||||
const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
|
const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
|
||||||
@@ -999,7 +991,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 跳转到用户主页
|
// 跳转到用户主页
|
||||||
const handleUserPress = (userId: string) => {
|
const handleUserPress = (userId: string) => {
|
||||||
navigation.navigate('UserProfile', { userId });
|
router.push(hrefs.hrefUserProfile(userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染身份标识
|
// 渲染身份标识
|
||||||
@@ -1294,24 +1286,18 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 评论标题 - 现代化设计 */}
|
{/* 评论标题 - 现代化简洁分区头 */}
|
||||||
<View style={[styles.commentTitle, { paddingVertical: responsiveGap }]}>
|
<View style={[styles.commentSectionHeader, { marginTop: responsiveGap, marginBottom: responsiveGap }]}>
|
||||||
<View style={styles.commentTitleLeft}>
|
<View style={styles.commentSectionTitleBlock}>
|
||||||
<View style={[styles.commentTitleIconContainer, { width: isDesktop ? 32 : 28, height: isDesktop ? 32 : 28 }]}>
|
<View style={styles.commentSectionTitleRow}>
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="chat-processing-outline"
|
|
||||||
size={isDesktop ? 18 : 16}
|
|
||||||
color={colors.primary.contrast}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
style={[
|
style={[
|
||||||
styles.commentTitleText,
|
styles.commentSectionTitle,
|
||||||
{ fontSize: isDesktop ? fontSizes.xl : fontSizes.lg }
|
{ fontSize: isDesktop ? fontSizes.xl : fontSizes.lg }
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
评论
|
评论区
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.commentCountBadge}>
|
<View style={styles.commentCountBadge}>
|
||||||
<Text variant="caption" style={styles.commentCountText}>{post.comments_count}</Text>
|
<Text variant="caption" style={styles.commentCountText}>{post.comments_count}</Text>
|
||||||
@@ -1319,6 +1305,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
|
}, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]);
|
||||||
|
|
||||||
@@ -1396,9 +1383,9 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
<View style={styles.emptyCommentsContainer}>
|
<View style={styles.emptyCommentsContainer}>
|
||||||
<View style={styles.emptyCommentsIconWrapper}>
|
<View style={styles.emptyCommentsIconWrapper}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="message-text-outline"
|
name="message-reply-text-outline"
|
||||||
size={isDesktop ? 48 : 40}
|
size={isDesktop ? 24 : 22}
|
||||||
color={colors.primary.main}
|
color={colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text variant="body" style={styles.emptyCommentsTitle}>
|
<Text variant="body" style={styles.emptyCommentsTitle}>
|
||||||
@@ -1831,7 +1818,7 @@ const styles = StyleSheet.create({
|
|||||||
postMetaInfo: {
|
postMetaInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'flex-start',
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
metaInfoMain: {
|
metaInfoMain: {
|
||||||
@@ -1928,38 +1915,42 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
fontWeight: '500',
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
commentTitleLeft: {
|
commentSectionTitle: {
|
||||||
flexDirection: 'row',
|
fontWeight: '800',
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
commentTitleIconContainer: {
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
},
|
|
||||||
commentTitleText: {
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
|
letterSpacing: -0.2,
|
||||||
},
|
},
|
||||||
commentCountBadge: {
|
commentCountBadge: {
|
||||||
backgroundColor: colors.primary.light + '25',
|
backgroundColor: colors.background.default,
|
||||||
paddingHorizontal: spacing.sm,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
paddingVertical: 2,
|
borderColor: colors.divider,
|
||||||
borderRadius: borderRadius.md,
|
paddingHorizontal: spacing.sm + 2,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
minWidth: 24,
|
minWidth: 24,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
commentCountText: {
|
commentCountText: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: colors.primary.main,
|
color: colors.text.secondary,
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
@@ -2067,17 +2058,26 @@ const styles = StyleSheet.create({
|
|||||||
emptyCommentsContainer: {
|
emptyCommentsContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: spacing.xl * 2,
|
marginHorizontal: spacing.lg,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
paddingVertical: spacing.xl + spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
emptyCommentsIconWrapper: {
|
emptyCommentsIconWrapper: {
|
||||||
width: 72,
|
width: 44,
|
||||||
height: 72,
|
height: 44,
|
||||||
borderRadius: 36,
|
borderRadius: 22,
|
||||||
backgroundColor: colors.primary.light + '18',
|
backgroundColor: colors.background.default,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.divider,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
emptyCommentsTitle: {
|
emptyCommentsTitle: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
RefreshControl,
|
RefreshControl,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import { Post, User } from '../../types';
|
import { Post, User } from '../../types';
|
||||||
@@ -24,12 +23,10 @@ import { postService, authService } from '../../services';
|
|||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
import type { PostCardAction } from '../../components/business/PostCard';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
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 { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
|
|
||||||
|
|
||||||
const TABS = ['帖子', '用户'];
|
const TABS = ['帖子', '用户'];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -37,12 +34,10 @@ type SearchType = 'posts' | 'users';
|
|||||||
|
|
||||||
interface SearchScreenProps {
|
interface SearchScreenProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
navigation?: NavigationProp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation: propNavigation }) => {
|
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||||
// 如果传入了 navigation 则使用传入的,否则使用 hook 获取的
|
const router = useRouter();
|
||||||
const navigation = propNavigation || useNavigation<NavigationProp>();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||||
|
|
||||||
@@ -168,12 +163,12 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
|
|
||||||
// 跳转到帖子详情
|
// 跳转到帖子详情
|
||||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||||
navigation.navigate('PostDetail', { postId, scrollToComments });
|
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转到用户主页
|
// 跳转到用户主页
|
||||||
const handleUserPress = (userId: string) => {
|
const handleUserPress = (userId: string) => {
|
||||||
navigation.navigate('UserProfile', { userId });
|
router.push(hrefs.hrefUserProfile(userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 统一处理 PostCard 的操作(搜索页不支持删除)
|
// 统一处理 PostCard 的操作(搜索页不支持删除)
|
||||||
@@ -515,7 +510,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||||
onPress={() => onBack ? onBack() : navigation.goBack()}
|
onPress={() => (onBack ? onBack() : router.back())}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation, useRouter } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { messageManager } from '../../stores';
|
import { messageManager } from '../../stores';
|
||||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +50,8 @@ import {
|
|||||||
} from './components/ChatScreen';
|
} from './components/ChatScreen';
|
||||||
|
|
||||||
export const ChatScreen: React.FC = () => {
|
export const ChatScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<any>();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
@@ -59,21 +61,9 @@ export const ChatScreen: React.FC = () => {
|
|||||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWideScreen) {
|
if (isWideScreen) {
|
||||||
// 导航到大屏幕模式的主界面首页
|
router.replace(hrefs.hrefHome());
|
||||||
navigation.reset({
|
|
||||||
index: 0,
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
name: 'Main',
|
|
||||||
params: {
|
|
||||||
screen: 'HomeTab',
|
|
||||||
params: { screen: 'Home' }
|
|
||||||
}
|
}
|
||||||
}
|
}, [isWideScreen, router]);
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isWideScreen, navigation]);
|
|
||||||
|
|
||||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
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 handleReplyPreviewPress = useCallback((messageId: string) => {
|
||||||
const targetId = String(messageId);
|
const targetId = String(messageId);
|
||||||
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||||
@@ -245,14 +225,13 @@ export const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
replyTargetMessageIdRef.current = targetId;
|
replyTargetMessageIdRef.current = targetId;
|
||||||
setBrowsingHistory(true);
|
setBrowsingHistory(true);
|
||||||
highlightMessageTemporarily(targetId);
|
|
||||||
|
|
||||||
flatListRef.current?.scrollToIndex({
|
flatListRef.current?.scrollToIndex({
|
||||||
index: targetIndex,
|
index: targetIndex,
|
||||||
animated: true,
|
animated: true,
|
||||||
viewPosition: 0.5,
|
viewPosition: 0.5,
|
||||||
});
|
});
|
||||||
}, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]);
|
}, [displayMessages, flatListRef, setBrowsingHistory]);
|
||||||
|
|
||||||
// 监听返回事件,刷新会话列表
|
// 监听返回事件,刷新会话列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -399,7 +378,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
otherUser={otherUser}
|
otherUser={otherUser}
|
||||||
routeGroupName={routeGroupName}
|
routeGroupName={routeGroupName}
|
||||||
typingHint={typingHint}
|
typingHint={typingHint}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => router.back()}
|
||||||
onTitlePress={navigateToInfo}
|
onTitlePress={navigateToInfo}
|
||||||
onMorePress={navigateToChatSettings}
|
onMorePress={navigateToChatSettings}
|
||||||
onGroupInfoPress={handleGroupInfoPress}
|
onGroupInfoPress={handleGroupInfoPress}
|
||||||
@@ -431,8 +410,8 @@ export const ChatScreen: React.FC = () => {
|
|||||||
initialNumToRender={14}
|
initialNumToRender={14}
|
||||||
maxToRenderPerBatch={10}
|
maxToRenderPerBatch={10}
|
||||||
updateCellsBatchingPeriod={50}
|
updateCellsBatchingPeriod={50}
|
||||||
windowSize={9}
|
windowSize={15}
|
||||||
removeClippedSubviews={Platform.OS !== 'web'}
|
removeClippedSubviews={false}
|
||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
onScrollBeginDrag={() => {
|
onScrollBeginDrag={() => {
|
||||||
isUserDraggingRef.current = true;
|
isUserDraggingRef.current = true;
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||||
@@ -25,13 +24,10 @@ import { groupService } from '../../services/groupService';
|
|||||||
import { uploadService } from '../../services/uploadService';
|
import { uploadService } from '../../services/uploadService';
|
||||||
import { Avatar, Text, Button, Loading } from '../../components/common';
|
import { Avatar, Text, Button, Loading } from '../../components/common';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const CreateGroupScreen: React.FC = () => {
|
const CreateGroupScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
@@ -138,7 +134,7 @@ const CreateGroupScreen: React.FC = () => {
|
|||||||
Alert.alert('成功', '群组创建成功', [
|
Alert.alert('成功', '群组创建成功', [
|
||||||
{
|
{
|
||||||
text: '确定',
|
text: '确定',
|
||||||
onPress: () => navigation.goBack(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||||
@@ -39,16 +39,14 @@ import {
|
|||||||
JoinType,
|
JoinType,
|
||||||
} from '../../types/dto';
|
} from '../../types/dto';
|
||||||
import { User } from '../../types';
|
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 { messageManager } from '../../stores/messageManager';
|
||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
type GroupInfoRouteProp = RouteProp<RootStackParamList, 'GroupInfo'>;
|
|
||||||
|
|
||||||
// 加群方式选项
|
// 加群方式选项
|
||||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [
|
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [
|
||||||
{ value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' },
|
{ 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 GroupInfoScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const route = useRoute<GroupInfoRouteProp>();
|
const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{
|
||||||
const { groupId, conversationId } = route.params;
|
groupId?: string | string[];
|
||||||
|
conversationId?: string | string[];
|
||||||
|
}>();
|
||||||
|
const groupId = firstRouteParam(groupIdParam) ?? '';
|
||||||
|
const conversationId = firstRouteParam(conversationIdParam);
|
||||||
const { currentUser } = useAuthStore();
|
const { currentUser } = useAuthStore();
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
@@ -112,6 +114,7 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 加载群组信息
|
// 加载群组信息
|
||||||
const loadGroupInfo = useCallback(async () => {
|
const loadGroupInfo = useCallback(async () => {
|
||||||
|
if (!groupId) return;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载群组信息失败:', error);
|
console.error('加载群组信息失败:', error);
|
||||||
Alert.alert('错误', '加载群组信息失败', [
|
Alert.alert('错误', '加载群组信息失败', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() },
|
{ text: '确定', onPress: () => router.back() },
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -394,7 +397,7 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await groupService.dissolveGroup(groupId);
|
await groupService.dissolveGroup(groupId);
|
||||||
Alert.alert('成功', '群组已解散', [
|
Alert.alert('成功', '群组已解散', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() },
|
{ text: '确定', onPress: () => router.back() },
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('解散群组失败:', error);
|
console.error('解散群组失败:', error);
|
||||||
@@ -420,7 +423,7 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await groupService.leaveGroup(groupId);
|
await groupService.leaveGroup(groupId);
|
||||||
Alert.alert('成功', '已退出群聊', [
|
Alert.alert('成功', '已退出群聊', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() },
|
{ text: '确定', onPress: () => router.back() },
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('退出群聊失败:', error);
|
console.error('退出群聊失败:', error);
|
||||||
@@ -464,7 +467,7 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 跳转到成员管理
|
// 跳转到成员管理
|
||||||
const goToMembers = () => {
|
const goToMembers = () => {
|
||||||
navigation.navigate('GroupMembers' as any, { groupId });
|
router.push(hrefs.hrefGroupMembers(groupId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取加群方式文本
|
// 获取加群方式文本
|
||||||
@@ -485,8 +488,8 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatGroupNo = (id: string | number) => {
|
const formatGroupNo = (id: string) => {
|
||||||
const raw = String(id);
|
const raw = id;
|
||||||
if (raw.length <= 12) return raw;
|
if (raw.length <= 12) return raw;
|
||||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native';
|
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacity } from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
import { Avatar, Text } from '../../components/common';
|
import { Avatar, Text } from '../../components/common';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { routePayloadCache } from '../../stores/routePayloadCache';
|
||||||
import { groupService } from '../../services/groupService';
|
import { groupService } from '../../services/groupService';
|
||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import { GroupMemberResponse } from '../../types/dto';
|
import { GroupMemberResponse } from '../../types/dto';
|
||||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||||
|
|
||||||
type Route = RouteProp<RootStackParamList, 'GroupInviteDetail'>;
|
|
||||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const GroupInviteDetailScreen: React.FC = () => {
|
const GroupInviteDetailScreen: React.FC = () => {
|
||||||
const route = useRoute<Route>();
|
const router = useRouter();
|
||||||
const navigation = useNavigation<Navigation>();
|
const { messageId } = useLocalSearchParams<{ messageId?: string }>();
|
||||||
const { message } = route.params;
|
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
|
<View style={styles.emptyWrap}>
|
||||||
|
<Text variant="body" color="secondary">
|
||||||
|
无法加载该消息,请从通知列表重新打开。
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||||
|
<Text variant="body">返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { extra_data } = message;
|
const { extra_data } = message;
|
||||||
|
|
||||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||||
@@ -35,7 +46,7 @@ const GroupInviteDetailScreen: React.FC = () => {
|
|||||||
if (!extra_data?.group_id) return;
|
if (!extra_data?.group_id) return;
|
||||||
setLoadingGroup(true);
|
setLoadingGroup(true);
|
||||||
try {
|
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);
|
setMemberCount(group.member_count ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setMemberCount(null);
|
setMemberCount(null);
|
||||||
@@ -51,7 +62,7 @@ const GroupInviteDetailScreen: React.FC = () => {
|
|||||||
if (!extra_data?.group_id) return;
|
if (!extra_data?.group_id) return;
|
||||||
setLoadingMembers(true);
|
setLoadingMembers(true);
|
||||||
try {
|
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');
|
const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin');
|
||||||
setMembers(admins);
|
setMembers(admins);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -76,9 +87,9 @@ const GroupInviteDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await groupService.respondInvite(groupId, { flag, approve });
|
await groupService.respondInvite(String(groupId), { flag, approve });
|
||||||
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() },
|
{ text: '确定', onPress: () => router.back() },
|
||||||
]);
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('操作失败', '请稍后重试');
|
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) => {
|
const formatGroupNo = (id: string) => {
|
||||||
if (id.length <= 12) return id;
|
if (id.length <= 12) return id;
|
||||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||||
@@ -168,6 +179,16 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
|
emptyWrap: {
|
||||||
|
flex: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -38,8 +37,7 @@ import {
|
|||||||
GroupMemberResponse,
|
GroupMemberResponse,
|
||||||
GroupRole,
|
GroupRole,
|
||||||
} from '../../types/dto';
|
} from '../../types/dto';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { firstRouteParam } from '../../navigation/paramUtils';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
|
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
|
||||||
|
|
||||||
@@ -50,9 +48,6 @@ const GRID_CONFIG = {
|
|||||||
desktop: { columns: 3, itemWidth: '31%' },
|
desktop: { columns: 3, itemWidth: '31%' },
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
type GroupMembersRouteProp = RouteProp<RootStackParamList, 'GroupMembers'>;
|
|
||||||
|
|
||||||
// 成员分组
|
// 成员分组
|
||||||
interface MemberGroup {
|
interface MemberGroup {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -60,9 +55,8 @@ interface MemberGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GroupMembersScreen: React.FC = () => {
|
const GroupMembersScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>();
|
||||||
const route = useRoute<GroupMembersRouteProp>();
|
const groupId = firstRouteParam(groupIdParam) ?? '';
|
||||||
const { groupId } = route.params;
|
|
||||||
const { currentUser } = useAuthStore();
|
const { currentUser } = useAuthStore();
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
@@ -155,8 +149,9 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!groupId) return;
|
||||||
refresh();
|
refresh();
|
||||||
}, [groupId]);
|
}, [groupId, refresh]);
|
||||||
|
|
||||||
// 按角色分组
|
// 按角色分组
|
||||||
const groupMembers = useCallback((): MemberGroup[] => {
|
const groupMembers = useCallback((): MemberGroup[] => {
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
import { Avatar, Text } from '../../components/common';
|
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 { groupService } from '../../services/groupService';
|
||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import { userManager } from '../../stores/userManager';
|
import { userManager } from '../../stores/userManager';
|
||||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||||
|
|
||||||
type Route = RouteProp<RootStackParamList, 'GroupRequestDetail'>;
|
|
||||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const GroupRequestDetailScreen: React.FC = () => {
|
const GroupRequestDetailScreen: React.FC = () => {
|
||||||
const route = useRoute<Route>();
|
const router = useRouter();
|
||||||
const navigation = useNavigation<Navigation>();
|
const { messageId } = useLocalSearchParams<{ messageId?: string }>();
|
||||||
const { message } = route.params;
|
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
|
<View style={styles.emptyWrap}>
|
||||||
|
<Text variant="body" color="secondary">
|
||||||
|
无法加载该消息,请从通知列表重新打开。
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||||
|
<Text variant="body">返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { extra_data } = message;
|
const { extra_data } = message;
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||||
@@ -64,7 +77,7 @@ const GroupRequestDetailScreen: React.FC = () => {
|
|||||||
if (!extra_data?.group_id) return;
|
if (!extra_data?.group_id) return;
|
||||||
setLoadingGroup(true);
|
setLoadingGroup(true);
|
||||||
try {
|
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);
|
setMemberCount(group.member_count ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setMemberCount(null);
|
setMemberCount(null);
|
||||||
@@ -90,18 +103,18 @@ const GroupRequestDetailScreen: React.FC = () => {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (message.system_type === 'group_invite') {
|
if (message.system_type === 'group_invite') {
|
||||||
await groupService.respondInvite(groupId, {
|
await groupService.respondInvite(String(groupId), {
|
||||||
flag,
|
flag,
|
||||||
approve,
|
approve,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await groupService.reviewJoinRequest(groupId, {
|
await groupService.reviewJoinRequest(String(groupId), {
|
||||||
flag,
|
flag,
|
||||||
approve,
|
approve,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [
|
Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [
|
||||||
{ text: '确定', onPress: () => navigation.goBack() },
|
{ text: '确定', onPress: () => router.back() },
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert('操作失败', '请稍后重试');
|
Alert.alert('操作失败', '请稍后重试');
|
||||||
@@ -114,7 +127,7 @@ const GroupRequestDetailScreen: React.FC = () => {
|
|||||||
if (!applicantId) return;
|
if (!applicantId) return;
|
||||||
const user = await userManager.getUserById(applicantId);
|
const user = await userManager.getUserById(applicantId);
|
||||||
if (user?.id) {
|
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 = () => {
|
|||||||
<GroupInfoSummaryCard
|
<GroupInfoSummaryCard
|
||||||
groupName={extra_data?.group_name}
|
groupName={extra_data?.group_name}
|
||||||
groupAvatar={extra_data?.group_avatar}
|
groupAvatar={extra_data?.group_avatar}
|
||||||
groupNo={extra_data?.group_id}
|
groupNo={String(extra_data?.group_id ?? '')}
|
||||||
groupDescription={extra_data?.group_description}
|
groupDescription={extra_data?.group_description}
|
||||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||||
/>
|
/>
|
||||||
@@ -166,6 +179,16 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
|
emptyWrap: {
|
||||||
|
flex: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
import { colors, spacing, borderRadius } from '../../theme';
|
import { colors, spacing, borderRadius } from '../../theme';
|
||||||
@@ -19,15 +18,12 @@ import Avatar from '../../components/common/Avatar';
|
|||||||
import Text from '../../components/common/Text';
|
import Text from '../../components/common/Text';
|
||||||
import { groupService } from '../../services/groupService';
|
import { groupService } from '../../services/groupService';
|
||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
|
||||||
import { GroupResponse, JoinType } from '../../types/dto';
|
import { GroupResponse, JoinType } from '../../types/dto';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { EmptyState } from '../../components/common';
|
import { EmptyState } from '../../components/common';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
const JoinGroupScreen: React.FC = () => {
|
const JoinGroupScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
|
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
|
||||||
@@ -92,7 +88,7 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
Alert.alert('成功', '操作已提交', [
|
Alert.alert('成功', '操作已提交', [
|
||||||
{
|
{
|
||||||
text: '确定',
|
text: '确定',
|
||||||
onPress: () => navigation.goBack(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -106,13 +102,13 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyGroupId = (groupId: string | number) => {
|
const handleCopyGroupId = (groupId: string) => {
|
||||||
Clipboard.setString(String(groupId));
|
Clipboard.setString(groupId);
|
||||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatGroupNo = (id: string | number) => {
|
const formatGroupNo = (id: string) => {
|
||||||
const raw = String(id);
|
const raw = id;
|
||||||
if (raw.length <= 12) return raw;
|
if (raw.length <= 12) return raw;
|
||||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
|
||||||
@@ -43,9 +43,9 @@ import {
|
|||||||
useMarkAsRead,
|
useMarkAsRead,
|
||||||
useMessageManagerConversations,
|
useMessageManagerConversations,
|
||||||
} from '../../stores';
|
} 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 { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||||
import { ConversationListRow } from './components/ConversationListRow';
|
import { ConversationListRow } from './components/ConversationListRow';
|
||||||
@@ -54,9 +54,6 @@ import { NotificationsScreen } from './NotificationsScreen';
|
|||||||
// 导入扫码组件
|
// 导入扫码组件
|
||||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
|
||||||
|
|
||||||
// 系统通知会话特殊ID
|
// 系统通知会话特殊ID
|
||||||
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
|
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
|
||||||
|
|
||||||
@@ -76,7 +73,7 @@ interface SearchResultItem {
|
|||||||
* 支持响应式双栏布局
|
* 支持响应式双栏布局
|
||||||
*/
|
*/
|
||||||
export const MessageListScreen: React.FC = () => {
|
export const MessageListScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp & MessageNavProp>();
|
const router = useRouter();
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
||||||
@@ -286,22 +283,25 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
setSelectedConversation(conversation);
|
setSelectedConversation(conversation);
|
||||||
} else if (conversation.type === 'group' && conversation.group) {
|
} else if (conversation.type === 'group' && conversation.group) {
|
||||||
// 群聊 - 窄屏下导航
|
// 群聊 - 窄屏下导航
|
||||||
(navigation as any).navigate('Chat', {
|
router.push(
|
||||||
|
hrefs.hrefChat({
|
||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
groupId: String(conversation.group.id),
|
groupId: String(conversation.group.id),
|
||||||
groupName: conversation.group.name,
|
groupName: conversation.group.name,
|
||||||
isGroupChat: true,
|
isGroupChat: true,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 私聊 - 窄屏下导航
|
// 私聊 - 窄屏下导航
|
||||||
const currentUserId = useAuthStore.getState().currentUser?.id;
|
const currentUserId = useAuthStore.getState().currentUser?.id;
|
||||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||||
(navigation as any).navigate('Chat', {
|
router.push(
|
||||||
|
hrefs.hrefChat({
|
||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
userId: otherUser ? String(otherUser.id) : undefined,
|
userId: otherUser ? String(otherUser.id) : undefined,
|
||||||
userName: otherUser?.nickname || otherUser?.username,
|
|
||||||
isGroupChat: false,
|
isGroupChat: false,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -311,12 +311,12 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 创建群聊
|
// 创建群聊
|
||||||
const handleCreateGroup = () => {
|
const handleCreateGroup = () => {
|
||||||
(navigation as any).navigate('CreateGroup');
|
router.push(hrefs.hrefGroupCreate());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 主动加群
|
// 主动加群
|
||||||
const handleJoinGroup = () => {
|
const handleJoinGroup = () => {
|
||||||
(navigation as any).navigate('JoinGroup');
|
router.push(hrefs.hrefGroupJoin());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenActionMenu = () => {
|
const handleOpenActionMenu = () => {
|
||||||
@@ -581,12 +581,13 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
const conversation = await createConversation(String(user.id));
|
const conversation = await createConversation(String(user.id));
|
||||||
|
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
(navigation as any).navigate('Chat', {
|
router.push(
|
||||||
|
hrefs.hrefChat({
|
||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
userId: String(user.id),
|
userId: String(user.id),
|
||||||
userName: user.nickname || user.username,
|
|
||||||
isGroupChat: false,
|
isGroupChat: false,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建会话失败:', error);
|
console.error('创建会话失败:', error);
|
||||||
@@ -660,9 +661,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
const renderSearchMode = () => (
|
const renderSearchMode = () => (
|
||||||
<View style={styles.searchModeContainer}>
|
<View style={styles.searchModeContainer}>
|
||||||
<View style={styles.searchInputContainer}>
|
<View style={styles.searchInputContainer}>
|
||||||
<TouchableOpacity onPress={handleCloseSearch}>
|
<AppBackButton onPress={handleCloseSearch} iconColor="#666" />
|
||||||
<MaterialCommunityIcons name="arrow-left" size={22} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||||
|
|||||||
@@ -18,17 +18,16 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useIsFocused } from '@react-navigation/native';
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
import { SystemMessageResponse } from '../../types/dto';
|
import { SystemMessageResponse } from '../../types/dto';
|
||||||
import { messageService } from '../../services/messageService';
|
import { messageService } from '../../services/messageService';
|
||||||
import { commentService } from '../../services/commentService';
|
import { commentService } from '../../services/commentService';
|
||||||
import { SystemMessageItem } from '../../components/business';
|
import { SystemMessageItem } from '../../components/business';
|
||||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useMessageManagerSystemUnreadCount } from '../../stores';
|
import { useMessageManagerSystemUnreadCount } from '../../stores';
|
||||||
import { messageManager } from '../../stores/messageManager';
|
import { messageManager } from '../../stores/messageManager';
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_j
|
|||||||
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const router = useRouter();
|
||||||
|
|
||||||
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
|
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
|
||||||
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
|
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);
|
const postId = await resolvePostId(message);
|
||||||
if (postId) {
|
if (postId) {
|
||||||
navigation.navigate('PostDetail', { postId });
|
router.push(hrefs.hrefPostDetail(postId));
|
||||||
}
|
}
|
||||||
} else if (system_type === 'follow') {
|
} else if (system_type === 'follow') {
|
||||||
// 关注 - 跳转到用户主页
|
// 关注 - 跳转到用户主页
|
||||||
if (extra_data?.actor_id_str) {
|
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') {
|
} else if (system_type === 'group_join_apply') {
|
||||||
navigation.navigate('GroupRequestDetail', { message });
|
router.push(hrefs.hrefGroupRequestDetail(message));
|
||||||
} else if (system_type === 'group_invite') {
|
} else if (system_type === 'group_invite') {
|
||||||
navigation.navigate('GroupInviteDetail', { message });
|
router.push(hrefs.hrefGroupInviteDetail(message));
|
||||||
}
|
}
|
||||||
// 其他类型暂不处理跳转
|
// 其他类型暂不处理跳转
|
||||||
} catch (error) {
|
} 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);
|
const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null);
|
||||||
|
|
||||||
if (actorId) {
|
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 (
|
return (
|
||||||
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
||||||
{shouldShowBackButton ? (
|
{shouldShowBackButton ? (
|
||||||
<TouchableOpacity
|
<AppBackButton style={styles.backButton} onPress={onBack} />
|
||||||
style={styles.backButton}
|
|
||||||
onPress={onBack}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -30,16 +29,20 @@ import { messageManager } from '../../stores/messageManager';
|
|||||||
import { userManager } from '../../stores/userManager';
|
import { userManager } from '../../stores/userManager';
|
||||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
|
|
||||||
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
|
|
||||||
|
|
||||||
const PrivateChatInfoScreen: React.FC = () => {
|
const PrivateChatInfoScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const route = useRoute<PrivateChatInfoRouteProp>();
|
const raw = useLocalSearchParams<{
|
||||||
const { conversationId, userId, userName, userAvatar } = route.params;
|
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();
|
const { currentUser } = useAuthStore();
|
||||||
|
|
||||||
// 用户信息状态
|
// 用户信息状态
|
||||||
@@ -150,8 +153,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 查看用户资料
|
// 查看用户资料
|
||||||
const handleViewProfile = () => {
|
const handleViewProfile = () => {
|
||||||
// 使用导航服务跳转到用户资料页面
|
router.push(hrefs.hrefUserProfile(userId));
|
||||||
navigationService.navigate('UserProfile', { userId });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 举报/投诉
|
// 举报/投诉
|
||||||
@@ -219,7 +221,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setIsBlocked(true);
|
setIsBlocked(true);
|
||||||
messageManager.removeConversation(conversationId);
|
messageManager.removeConversation(conversationId);
|
||||||
navigation.navigate('MessageList' as any);
|
router.replace(hrefs.hrefMessages());
|
||||||
Alert.alert('已拉黑', '你已成功拉黑该用户');
|
Alert.alert('已拉黑', '你已成功拉黑该用户');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||||
@@ -245,7 +247,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
|||||||
await messageService.deleteConversationForSelf(conversationId);
|
await messageService.deleteConversationForSelf(conversationId);
|
||||||
messageManager.removeConversation(conversationId);
|
messageManager.removeConversation(conversationId);
|
||||||
// 返回消息列表
|
// 返回消息列表
|
||||||
navigation.navigate('MessageList' as any);
|
router.replace(hrefs.hrefMessages());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof ApiError ? error.message : '删除聊天失败';
|
const msg = error instanceof ApiError ? error.message : '删除聊天失败';
|
||||||
Alert.alert('错误', msg);
|
Alert.alert('错误', msg);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import React, { useMemo } from 'react';
|
|||||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
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 { colors, spacing } from '../../../../theme';
|
||||||
import { chatScreenStyles as baseStyles } from './styles';
|
import { chatScreenStyles as baseStyles } from './styles';
|
||||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||||
import { ChatHeaderProps } from './types';
|
import { ChatHeaderProps } from './types';
|
||||||
|
|
||||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||||
@@ -26,7 +26,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
isWideScreen: propIsWideScreen,
|
isWideScreen: propIsWideScreen,
|
||||||
}) => {
|
}) => {
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
const { width } = useResponsive();
|
|
||||||
// 使用 768px 作为大屏幕和小屏幕的分界线
|
// 使用 768px 作为大屏幕和小屏幕的分界线
|
||||||
const isWideScreenHook = useBreakpointGTE('lg');
|
const isWideScreenHook = useBreakpointGTE('lg');
|
||||||
// 优先使用 props 传入的 isWideScreen,否则使用 hook
|
// 优先使用 props 传入的 isWideScreen,否则使用 hook
|
||||||
@@ -74,12 +73,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||||
{!isWideScreen ? (
|
{!isWideScreen ? (
|
||||||
<TouchableOpacity
|
<AppBackButton style={styles.backButton} onPress={onBack} />
|
||||||
style={styles.backButton}
|
|
||||||
onPress={onBack}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.backButton} />
|
<View style={styles.backButton} />
|
||||||
)}
|
)}
|
||||||
@@ -131,14 +125,14 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
style={styles.moreButton}
|
style={styles.moreButton}
|
||||||
onPress={onGroupInfoPress}
|
onPress={onGroupInfoPress}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.moreButton}
|
style={styles.moreButton}
|
||||||
onPress={onMorePress}
|
onPress={onMorePress}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
const data = s.data as ImageSegmentData;
|
const data = s.data as ImageSegmentData;
|
||||||
return {
|
return {
|
||||||
id: `img-${message.id}-${idx}`,
|
id: `img-${message.id}-${idx}`,
|
||||||
url: data.url,
|
url: data.url || data.thumbnail_url || '',
|
||||||
thumbnail_url: data.thumbnail_url,
|
thumbnail_url: data.thumbnail_url,
|
||||||
width: data.width,
|
width: data.width,
|
||||||
height: data.height,
|
height: data.height,
|
||||||
@@ -128,7 +128,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 检查当前消息是否被选中
|
// 检查当前消息是否被选中
|
||||||
const isSelected = selectedMessageId === String(message.id);
|
|
||||||
|
|
||||||
// 获取发送者信息(群聊)- 供子组件使用
|
// 获取发送者信息(群聊)- 供子组件使用
|
||||||
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
|
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
|
||||||
@@ -307,7 +306,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
isMe ? styles.myBubble : styles.theirBubble,
|
isMe ? styles.myBubble : styles.theirBubble,
|
||||||
hasReply && segmentStyles.replyBubble,
|
hasReply && segmentStyles.replyBubble,
|
||||||
isPureImageMessage && segmentStyles.pureImageBubble,
|
isPureImageMessage && segmentStyles.pureImageBubble,
|
||||||
isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble),
|
|
||||||
]}>
|
]}>
|
||||||
<MessageSegmentsRenderer
|
<MessageSegmentsRenderer
|
||||||
segments={segments}
|
segments={segments}
|
||||||
@@ -320,7 +318,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
onReplyPress={onReplyPress}
|
onReplyPress={onReplyPress}
|
||||||
onImagePress={(url) => {
|
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) {
|
if (clickIndex !== -1 && onImagePress) {
|
||||||
onImagePress(imageSegments, clickIndex);
|
onImagePress(imageSegments, clickIndex);
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.messageContent}>
|
<View style={[styles.messageContent, isMe ? styles.myMessageContentPanel : null]}>
|
||||||
{/* 群聊模式:显示发送者昵称 */}
|
{/* 群聊模式:显示发送者昵称 */}
|
||||||
{isGroupChat && !isMe && senderInfo && (
|
{isGroupChat && !isMe && senderInfo && (
|
||||||
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
||||||
@@ -483,16 +483,4 @@ const segmentStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 React.memo 优化渲染性能
|
export default MessageBubble;
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 用于渲染消息链中的各种 Segment 类型
|
* 用于渲染消息链中的各种 Segment 类型
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -131,11 +131,21 @@ const ImageSegment: React.FC<{
|
|||||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||||
const [loadError, setLoadError] = useState(false);
|
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,显示图片占位符
|
// 如果没有有效的图片URL,显示图片占位符
|
||||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 切换消息图片时重置状态,避免列表复用导致旧状态污染
|
||||||
|
setCurrentImageUri(data.thumbnail_url || data.url || '');
|
||||||
|
setDimensions(null);
|
||||||
|
setLoadError(false);
|
||||||
|
}, [data.thumbnail_url, data.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
setLoadError(false);
|
setLoadError(false);
|
||||||
@@ -169,9 +179,7 @@ const ImageSegment: React.FC<{
|
|||||||
|
|
||||||
// 添加超时处理,防止高分辨率图片加载卡住
|
// 添加超时处理,防止高分辨率图片加载卡住
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!dimensions) {
|
setDimensions(prev => prev || IMAGE_FALLBACK_SIZE);
|
||||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
|
||||||
}
|
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// 否则从图片获取实际尺寸
|
// 否则从图片获取实际尺寸
|
||||||
@@ -196,7 +204,7 @@ const ImageSegment: React.FC<{
|
|||||||
|
|
||||||
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||||
},
|
},
|
||||||
(error) => {
|
() => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
// 获取失败时使用默认 4:3 比例
|
// 获取失败时使用默认 4:3 比例
|
||||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||||
@@ -221,10 +229,9 @@ const ImageSegment: React.FC<{
|
|||||||
if (!hasValidUrl || loadError) {
|
if (!hasValidUrl || loadError) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`image-${data.url || Math.random()}`}
|
|
||||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={() => hasValidUrl && onImagePress?.(data.url)}
|
onPress={() => hasValidUrl && onImagePress?.(pressUrl)}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={500}
|
delayLongPress={500}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
@@ -250,10 +257,9 @@ const ImageSegment: React.FC<{
|
|||||||
if (!dimensions) {
|
if (!dimensions) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`image-${data.url}`}
|
|
||||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={() => onImagePress?.(data.url)}
|
onPress={() => onImagePress?.(pressUrl)}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={500}
|
delayLongPress={500}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
@@ -272,10 +278,9 @@ const ImageSegment: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`image-${data.url}`}
|
|
||||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={() => onImagePress?.(data.url)}
|
onPress={() => onImagePress?.(pressUrl)}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={500}
|
delayLongPress={500}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
@@ -287,11 +292,20 @@ const ImageSegment: React.FC<{
|
|||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
|
recyclingKey={stableImageKey}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
cachePolicy="disk"
|
cachePolicy="memory-disk"
|
||||||
priority="normal"
|
priority="normal"
|
||||||
transition={200}
|
transition={200}
|
||||||
|
onLoadStart={() => {
|
||||||
|
setLoadError(false);
|
||||||
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
|
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
|
||||||
|
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
|
||||||
|
setCurrentImageUri(data.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadError(true);
|
setLoadError(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -680,7 +694,9 @@ export const MessageSegmentsRenderer: React.FC<{
|
|||||||
{/* 其他 Segment 内容 */}
|
{/* 其他 Segment 内容 */}
|
||||||
<View style={styles.segmentsContent}>
|
<View style={styles.segmentsContent}>
|
||||||
{otherSegments.map((segment, index) => (
|
{otherSegments.map((segment, index) => (
|
||||||
<React.Fragment key={`segment-${index}-${segment.type}`}>
|
<React.Fragment
|
||||||
|
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || index}`}
|
||||||
|
>
|
||||||
{renderSegment(segment, renderProps)}
|
{renderSegment(segment, renderProps)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -738,17 +754,17 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
},
|
},
|
||||||
atTextMe: {
|
atTextMe: {
|
||||||
color: '#1976D2', // 蓝色
|
color: '#4A88C7',
|
||||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
atTextOther: {
|
atTextOther: {
|
||||||
color: '#1976D2', // 蓝色
|
color: '#4A88C7',
|
||||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
atHighlight: {
|
atHighlight: {
|
||||||
backgroundColor: 'rgba(25, 118, 210, 0.2)',
|
backgroundColor: 'rgba(74, 136, 199, 0.2)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -966,7 +982,7 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
},
|
},
|
||||||
replyMe: {
|
replyMe: {
|
||||||
backgroundColor: 'rgba(25, 118, 210, 0.1)',
|
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||||
},
|
},
|
||||||
replyOther: {
|
replyOther: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||||
@@ -984,7 +1000,7 @@ const styles = StyleSheet.create({
|
|||||||
replySender: {
|
replySender: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#1976D2',
|
color: '#4A88C7',
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export const bubbleColors = {
|
|||||||
},
|
},
|
||||||
// 被回复的消息高亮
|
// 被回复的消息高亮
|
||||||
replyHighlight: {
|
replyHighlight: {
|
||||||
background: 'rgba(255, 107, 53, 0.08)',
|
background: 'rgba(74, 136, 199, 0.1)',
|
||||||
borderLeft: '#FF6B35',
|
borderLeft: '#4A88C7',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: '#1976D2',
|
color: '#4A88C7',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -215,7 +215,7 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
},
|
},
|
||||||
myBubble: {
|
myBubble: {
|
||||||
backgroundColor: '#E3F2FD', // Telegram风格的浅蓝色
|
backgroundColor: '#DFF2FF', // Telegram风格的浅蓝色
|
||||||
borderBottomRightRadius: 4, // 右下角尖,箭头在右下
|
borderBottomRightRadius: 4, // 右下角尖,箭头在右下
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
@@ -255,17 +255,23 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
// 选中状态
|
// 选中状态
|
||||||
selectedBubble: {
|
selectedBubble: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: '#007AFF',
|
borderColor: '#7FB6E6',
|
||||||
|
},
|
||||||
|
myMessageContentPanel: {
|
||||||
|
backgroundColor: '#DFF2FF',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
},
|
},
|
||||||
mySelectedBubble: {
|
mySelectedBubble: {
|
||||||
backgroundColor: '#0051D5',
|
backgroundColor: '#CFEAFF',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: 'rgba(255,255,255,0.5)',
|
borderColor: '#7FB6E6',
|
||||||
},
|
},
|
||||||
theirSelectedBubble: {
|
theirSelectedBubble: {
|
||||||
backgroundColor: '#E8F1FF',
|
backgroundColor: '#EEF5FC',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: '#007AFF',
|
borderColor: '#7FB6E6',
|
||||||
},
|
},
|
||||||
selectedText: {
|
selectedText: {
|
||||||
// 文本选中时的样式
|
// 文本选中时的样式
|
||||||
@@ -539,7 +545,7 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
marginRight: spacing.xs,
|
marginRight: spacing.xs,
|
||||||
},
|
},
|
||||||
panelTabActive: {
|
panelTabActive: {
|
||||||
backgroundColor: '#E3F2FD',
|
backgroundColor: '#DFF2FF',
|
||||||
},
|
},
|
||||||
panelTabEmoji: {
|
panelTabEmoji: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
@@ -1025,11 +1031,11 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
// 表情包选择状态
|
// 表情包选择状态
|
||||||
stickerItemSelected: {
|
stickerItemSelected: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: '#007AFF',
|
borderColor: '#7FB6E6',
|
||||||
},
|
},
|
||||||
stickerCheckOverlay: {
|
stickerCheckOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
backgroundColor: 'rgba(127, 182, 230, 0.14)',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
padding: 4,
|
padding: 4,
|
||||||
@@ -1045,8 +1051,8 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
stickerCheckBoxSelected: {
|
stickerCheckBoxSelected: {
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#7FB6E6',
|
||||||
borderColor: '#007AFF',
|
borderColor: '#7FB6E6',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 表情管理模态框
|
// 表情管理模态框
|
||||||
@@ -1077,12 +1083,12 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
manageDoneText: {
|
manageDoneText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#007AFF',
|
color: '#4A88C7',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
manageSelectText: {
|
manageSelectText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#007AFF',
|
color: '#4A88C7',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
manageInfoBar: {
|
manageInfoBar: {
|
||||||
@@ -1120,7 +1126,7 @@ export const chatScreenStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
manageSelectAllText: {
|
manageSelectAllText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: '#007AFF',
|
color: '#4A88C7',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
manageDeleteButton: {
|
manageDeleteButton: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
@@ -30,8 +30,8 @@ import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../..
|
|||||||
import { groupService } from '../../../../services/groupService';
|
import { groupService } from '../../../../services/groupService';
|
||||||
import { userManager } from '../../../../stores/userManager';
|
import { userManager } from '../../../../stores/userManager';
|
||||||
import { groupManager } from '../../../../stores/groupManager';
|
import { groupManager } from '../../../../stores/groupManager';
|
||||||
import { RootStackParamList } from '../../../../navigation/types';
|
import * as hrefs from '../../../../navigation/hrefs';
|
||||||
import { navigationService } from '../../../../infrastructure/navigation/navigationService';
|
import { firstRouteParam } from '../../../../navigation/paramUtils';
|
||||||
import {
|
import {
|
||||||
GroupMessage,
|
GroupMessage,
|
||||||
PanelType,
|
PanelType,
|
||||||
@@ -46,8 +46,6 @@ import {
|
|||||||
clearConversationMessages,
|
clearConversationMessages,
|
||||||
} from '../../../../services/database';
|
} from '../../../../services/database';
|
||||||
|
|
||||||
type ChatRouteProp = RouteProp<RootStackParamList, 'Chat'>;
|
|
||||||
|
|
||||||
export const useChatScreen = () => {
|
export const useChatScreen = () => {
|
||||||
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
|
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
|
||||||
if (error instanceof ApiError && error.message) {
|
if (error instanceof ApiError && error.message) {
|
||||||
@@ -56,17 +54,30 @@ export const useChatScreen = () => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const route = useRoute<ChatRouteProp>();
|
const rawParams = useLocalSearchParams<{
|
||||||
const navigation = useNavigation();
|
conversationId?: string | string[];
|
||||||
|
userId?: string | string[];
|
||||||
// 路由参数
|
isGroupChat?: string | string[];
|
||||||
const routeParams = useMemo(() => ({
|
groupId?: string | string[];
|
||||||
conversationId: route.params?.conversationId || null,
|
groupName?: string | string[];
|
||||||
userId: route.params?.userId || null,
|
}>();
|
||||||
isGroupChat: route.params?.isGroupChat || false,
|
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||||
groupId: route.params?.groupId,
|
const routeParams = useMemo(() => {
|
||||||
groupName: route.params?.groupName,
|
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||||
}), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.groupName]);
|
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;
|
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams;
|
||||||
|
|
||||||
@@ -399,8 +410,30 @@ export const useChatScreen = () => {
|
|||||||
console.error('获取成员信息失败:', error);
|
console.error('获取成员信息失败:', error);
|
||||||
}
|
}
|
||||||
} catch (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);
|
console.error('获取群组信息失败:', error);
|
||||||
Alert.alert('错误', '无法获取群组信息');
|
Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1129,7 +1162,7 @@ export const useChatScreen = () => {
|
|||||||
// 点击头像跳转到用户主页
|
// 点击头像跳转到用户主页
|
||||||
const handleAvatarPress = useCallback((userId: string) => {
|
const handleAvatarPress = useCallback((userId: string) => {
|
||||||
if (userId && userId !== currentUserId) {
|
if (userId && userId !== currentUserId) {
|
||||||
navigationService.navigate('UserProfile', { userId: String(userId) });
|
router.push(hrefs.hrefUserProfile(String(userId)));
|
||||||
}
|
}
|
||||||
}, [currentUserId]);
|
}, [currentUserId]);
|
||||||
|
|
||||||
@@ -1198,31 +1231,27 @@ export const useChatScreen = () => {
|
|||||||
// 导航到群组信息或用户资料
|
// 导航到群组信息或用户资料
|
||||||
const navigateToInfo = useCallback(() => {
|
const navigateToInfo = useCallback(() => {
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && routeGroupId) {
|
||||||
navigation.navigate('GroupInfo' as any, {
|
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
||||||
groupId: routeGroupId,
|
|
||||||
conversationId: conversationId || undefined,
|
|
||||||
});
|
|
||||||
} else if (otherUser?.id) {
|
} 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(() => {
|
const navigateToChatSettings = useCallback(() => {
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && routeGroupId) {
|
||||||
navigation.navigate('GroupInfo' as any, {
|
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
||||||
groupId: routeGroupId,
|
|
||||||
conversationId: conversationId || undefined,
|
|
||||||
});
|
|
||||||
} else if (otherUser?.id && conversationId) {
|
} else if (otherUser?.id && conversationId) {
|
||||||
navigation.navigate('PrivateChatInfo' as any, {
|
router.push(
|
||||||
conversationId: conversationId,
|
hrefs.hrefPrivateChatInfo({
|
||||||
|
conversationId,
|
||||||
userId: String(otherUser.id),
|
userId: String(otherUser.id),
|
||||||
userName: otherUser.nickname,
|
userName: otherUser.nickname,
|
||||||
userAvatar: otherUser.avatar,
|
userAvatar: otherUser.avatar,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]);
|
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
Animated,
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||||
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
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 { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||||
import { extractTextFromSegments } from '../../../types/dto';
|
import { extractTextFromSegments } from '../../../types/dto';
|
||||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||||
import { messageService } from '../../../services';
|
import { messageService } from '../../../services';
|
||||||
|
import * as hrefs from '../../../navigation/hrefs';
|
||||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ interface EmbeddedChatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
||||||
const navigation = useNavigation();
|
const router = useRouter();
|
||||||
const currentUser = useAuthStore(state => state.currentUser);
|
const currentUser = useAuthStore(state => state.currentUser);
|
||||||
|
|
||||||
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
|
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
|
||||||
@@ -190,21 +191,24 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
// 导航到完整聊天页面
|
// 导航到完整聊天页面
|
||||||
const handleNavigateToFullChat = () => {
|
const handleNavigateToFullChat = () => {
|
||||||
if (isGroupChat && conversation.group) {
|
if (isGroupChat && conversation.group) {
|
||||||
(navigation as any).navigate('Chat', {
|
router.push(
|
||||||
|
hrefs.hrefChat({
|
||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
groupId: String(conversation.group.id),
|
groupId: String(conversation.group.id),
|
||||||
groupName: conversation.group.name,
|
groupName: conversation.group.name,
|
||||||
isGroupChat: true,
|
isGroupChat: true,
|
||||||
});
|
}) as any
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const currentUserId = currentUser?.id;
|
const currentUserId = currentUser?.id;
|
||||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||||
(navigation as any).navigate('Chat', {
|
router.push(
|
||||||
|
hrefs.hrefChat({
|
||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
userId: otherUser ? String(otherUser.id) : undefined,
|
userId: otherUser ? String(otherUser.id) : undefined,
|
||||||
userName: otherUser?.nickname || otherUser?.username,
|
|
||||||
isGroupChat: false,
|
isGroupChat: false,
|
||||||
});
|
}) as any
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,9 +411,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||||
{!isWideScreen ? (
|
{!isWideScreen ? (
|
||||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
<AppBackButton onPress={onBack} style={styles.headerButton} iconColor="#666" />
|
||||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.headerButton} />
|
<View style={styles.headerButton} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
|
||||||
import { authService } from '../../services';
|
import { authService } from '../../services';
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
export const BlockedUsersScreen: React.FC = () => {
|
export const BlockedUsersScreen: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@@ -78,7 +80,7 @@ export const BlockedUsersScreen: React.FC = () => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.item}
|
style={styles.item}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
onPress={() => navigationService.navigate('UserProfile', { userId: item.id })}
|
onPress={() => router.push(hrefs.hrefUserProfile(item.id))}
|
||||||
>
|
>
|
||||||
<Avatar source={item.avatar} size={46} name={item.nickname} />
|
<Avatar source={item.avatar} size={46} name={item.nickname} />
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
|
|||||||
@@ -16,23 +16,19 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import { useAuthStore, useUserStore } from '../../stores';
|
import { useAuthStore, useUserStore } from '../../stores';
|
||||||
import { authService } from '../../services';
|
import { authService } from '../../services';
|
||||||
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
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';
|
import { useResponsive, useColumnCount } from '../../hooks';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'FollowList'>;
|
|
||||||
type FollowListRouteProp = RouteProp<HomeStackParamList, 'FollowList'>;
|
|
||||||
|
|
||||||
const FollowListScreen: React.FC = () => {
|
const FollowListScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const router = useRouter();
|
||||||
const route = useRoute<FollowListRouteProp>();
|
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
|
||||||
const { userId, type } = route.params;
|
const type = typeParam === 'followers' ? 'followers' : 'following';
|
||||||
|
|
||||||
const { currentUser } = useAuthStore();
|
const { currentUser } = useAuthStore();
|
||||||
const { followUser, unfollowUser } = useUserStore();
|
const { followUser, unfollowUser } = useUserStore();
|
||||||
@@ -135,7 +131,7 @@ const FollowListScreen: React.FC = () => {
|
|||||||
// 跳转到用户主页
|
// 跳转到用户主页
|
||||||
const handleUserPress = (targetUserId: string) => {
|
const handleUserPress = (targetUserId: string) => {
|
||||||
if (targetUserId !== currentUser?.id) {
|
if (targetUserId !== currentUser?.id) {
|
||||||
navigation.push('UserProfile', { userId: targetUserId });
|
router.push(hrefs.hrefUserProfile(targetUserId));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,11 +178,11 @@ const FollowListScreen: React.FC = () => {
|
|||||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||||
@{item.username}
|
@{item.username}
|
||||||
</Text>
|
</Text>
|
||||||
{item.bio && (
|
{item.bio?.trim() ? (
|
||||||
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
|
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
|
||||||
{item.bio}
|
{item.bio}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<Button
|
<Button
|
||||||
@@ -226,11 +222,11 @@ const FollowListScreen: React.FC = () => {
|
|||||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||||
@{item.username}
|
@{item.username}
|
||||||
</Text>
|
</Text>
|
||||||
{item.bio && (
|
{item.bio?.trim() ? (
|
||||||
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
|
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
|
||||||
{item.bio}
|
{item.bio}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<View style={styles.userCardFooter}>
|
<View style={styles.userCardFooter}>
|
||||||
|
|||||||
@@ -5,17 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import UserProfileScreen from './UserProfileScreen';
|
import UserProfileScreen from './UserProfileScreen';
|
||||||
import { ProfileStackParamList } from '../../navigation/types';
|
|
||||||
|
|
||||||
type ProfileNavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
|
|
||||||
|
|
||||||
export const ProfileScreen: React.FC = () => {
|
export const ProfileScreen: React.FC = () => {
|
||||||
const profileNavigation = useNavigation<ProfileNavigationProp>();
|
return <UserProfileScreen mode="self" />;
|
||||||
|
|
||||||
return <UserProfileScreen mode="self" profileNavigation={profileNavigation} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfileScreen;
|
export default ProfileScreen;
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
@@ -22,6 +22,8 @@ import { Text, ResponsiveContainer } from '../../components/common';
|
|||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
interface SettingsItem {
|
interface SettingsItem {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -65,7 +67,7 @@ const SETTINGS_GROUPS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const SettingsScreen: React.FC = () => {
|
export const SettingsScreen: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const router = useRouter();
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const { isWideScreen } = useResponsive();
|
const { isWideScreen } = useResponsive();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -78,16 +80,16 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
const handleItemPress = (key: string) => {
|
const handleItemPress = (key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'edit_profile':
|
case 'edit_profile':
|
||||||
navigation.navigate('EditProfile' as never);
|
router.push(hrefs.hrefProfileEdit());
|
||||||
break;
|
break;
|
||||||
case 'notification_settings':
|
case 'notification_settings':
|
||||||
navigation.navigate('NotificationSettings' as never);
|
router.push(hrefs.hrefProfileNotifications());
|
||||||
break;
|
break;
|
||||||
case 'blocked_users':
|
case 'blocked_users':
|
||||||
navigation.navigate('BlockedUsers' as never);
|
router.push(hrefs.hrefProfileBlocked());
|
||||||
break;
|
break;
|
||||||
case 'security':
|
case 'security':
|
||||||
navigation.navigate('AccountSecurity' as never);
|
router.push(hrefs.hrefProfileSecurity());
|
||||||
break;
|
break;
|
||||||
case 'logout':
|
case 'logout':
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -98,8 +100,9 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
{
|
{
|
||||||
text: '确定',
|
text: '确定',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: async () => {
|
||||||
logout();
|
await logout();
|
||||||
|
router.replace(hrefs.hrefAuthLogin());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,23 +12,19 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
||||||
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
import { ProfileStackParamList } from '../../navigation/types';
|
|
||||||
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile';
|
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile';
|
||||||
|
|
||||||
interface UserProfileScreenProps {
|
interface UserProfileScreenProps {
|
||||||
mode: ProfileMode;
|
mode: ProfileMode;
|
||||||
userId?: string; // 仅 other 模式需要
|
userId?: string; // 仅 other 模式需要
|
||||||
/** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */
|
|
||||||
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => {
|
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId }) => {
|
||||||
const { isDesktop, isTablet } = useResponsive();
|
const { isDesktop, isTablet } = useResponsive();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -51,7 +47,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
handleEditProfile,
|
handleEditProfile,
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
currentUser,
|
currentUser,
|
||||||
} = useUserProfile({ mode, userId, isDesktop, isTablet, profileNavigation });
|
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
||||||
|
|
||||||
// 渲染帖子列表
|
// 渲染帖子列表
|
||||||
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => {
|
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => {
|
||||||
|
|||||||
@@ -5,16 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import UserProfileScreen from './UserProfileScreen';
|
import UserProfileScreen from './UserProfileScreen';
|
||||||
import { HomeStackParamList } from '../../navigation/types';
|
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
|
|
||||||
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
|
|
||||||
|
|
||||||
export const UserScreen: React.FC = () => {
|
export const UserScreen: React.FC = () => {
|
||||||
const route = useRoute<UserRouteProp>();
|
const { userId: userIdParam } = useLocalSearchParams<{ userId?: string }>();
|
||||||
const userId = route.params?.userId || '';
|
const userId = userIdParam || '';
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const isSelfProfile = !!currentUser?.id && currentUser.id === userId;
|
const isSelfProfile = !!currentUser?.id && currentUser.id === userId;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user