diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/frontend.iml b/.idea/frontend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/frontend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..6752ffb --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,448 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ade1ecd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f3d93d7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App.tsx b/App.tsx index de54b07..771c3c4 100644 --- a/App.tsx +++ b/App.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useRef } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; +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'; @@ -42,6 +42,34 @@ const queryClient = new QueryClient({ installAlertOverride(); +// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline +if (Platform.OS === 'web') { + const style = document.createElement('style'); + style.textContent = ` + /* 移除TextInput focus时的浏览器默认outline */ + input:focus, textarea:focus, select:focus { + outline: none !important; + outline-width: 0 !important; + box-shadow: none !important; + } + + /* 移除focus-visible时的默认outline */ + input:focus-visible, textarea:focus-visible { + outline: none !important; + } + + /* 移除所有:focus相关的默认样式 */ + *:focus { + outline: none !important; + } + + *:focus-visible { + outline: none !important; + } + `; + document.head.appendChild(style); +} + export default function App() { const appState = useRef(AppState.currentState); const notificationResponseListener = useRef(null); diff --git a/screenshots/after-fix.png b/screenshots/after-fix.png new file mode 100644 index 0000000..2c35236 Binary files /dev/null and b/screenshots/after-fix.png differ diff --git a/screenshots/test-navigation-fix.png b/screenshots/test-navigation-fix.png new file mode 100644 index 0000000..060a680 Binary files /dev/null and b/screenshots/test-navigation-fix.png differ diff --git a/src/components/business/SearchBar.tsx b/src/components/business/SearchBar.tsx index 040bd8c..c1bf4b4 100644 --- a/src/components/business/SearchBar.tsx +++ b/src/components/business/SearchBar.tsx @@ -59,6 +59,9 @@ const SearchBar: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} autoFocus={autoFocus} + // 确保光标可见 + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> {value.length > 0 && ( = ({ requestStatus === 'pending' && !!onRequestAction; + // 使用固定的响应式值,兼容移动端和Web端 + const containerPadding = spacing.md; + const avatarSize = 44; + const iconSize = 22; + const contentFontSize = 14; + const titleFontSize = 14; + const timeFontSize = 12; + + const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + padding: containerPadding, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + iconContainer: { + marginRight: spacing.md, + }, + iconWrapper: { + width: avatarSize, + height: avatarSize, + borderRadius: avatarSize / 2, + justifyContent: 'center', + alignItems: 'center', + }, + content: { + flex: 1, + justifyContent: 'center', + minWidth: 0, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + title: { + fontWeight: '600', + flex: 1, + marginRight: spacing.sm, + fontSize: titleFontSize, + }, + time: { + fontSize: timeFontSize, + }, + messageContent: { + lineHeight: 20, + fontSize: contentFontSize, + }, + extraInfo: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + backgroundColor: colors.background.default, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + alignSelf: 'flex-start', + }, + extraText: { + marginLeft: spacing.xs, + flex: 1, + }, + actionsRow: { + flexDirection: 'row', + marginTop: spacing.sm, + }, + actionBtn: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.sm, + borderWidth: 1, + marginRight: spacing.sm, + }, + rejectBtn: { + borderColor: colors.error.light, + backgroundColor: colors.error.light + '18', + }, + approveBtn: { + borderColor: colors.success.light, + backgroundColor: colors.success.light + '18', + }, + arrowContainer: { + marginLeft: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + height: 20, + width: 24, + }, + }); + // 判断是否显示操作者头像 const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( message.system_type @@ -207,19 +300,19 @@ const SystemMessageItem: React.FC = ({ {showGroupAvatar ? ( ) : showOperatorAvatar ? ( ) : ( - + )} @@ -231,7 +324,7 @@ const SystemMessageItem: React.FC = ({ {title} - + {formatTime(message.created_at)} @@ -288,82 +381,4 @@ const SystemMessageItem: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-start', - padding: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - justifyContent: 'center', - }, - titleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - title: { - fontWeight: '600', - flex: 1, - marginRight: spacing.sm, - }, - messageContent: { - lineHeight: 18, - }, - extraInfo: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.xs, - backgroundColor: colors.background.default, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, - alignSelf: 'flex-start', - }, - extraText: { - marginLeft: spacing.xs, - flex: 1, - }, - actionsRow: { - flexDirection: 'row', - marginTop: spacing.sm, - }, - actionBtn: { - paddingVertical: spacing.xs, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.sm, - borderWidth: 1, - marginRight: spacing.sm, - }, - rejectBtn: { - borderColor: colors.error.light, - backgroundColor: colors.error.light + '18', - }, - approveBtn: { - borderColor: colors.success.light, - backgroundColor: colors.success.light + '18', - }, - arrowContainer: { - marginLeft: spacing.sm, - justifyContent: 'center', - alignItems: 'center', - height: 20, - }, -}); - export default SystemMessageItem; diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index f677003..a4f958c 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -105,6 +105,9 @@ const Input: React.FC = ({ autoCapitalize={autoCapitalize} keyboardType={keyboardType} autoCorrect={autoCorrect} + // 确保光标可见 + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> {rightIcon && ( { + console.log('[Debug Sidebar]', { + width, + isCollapsed, + activeTab, + unreadCount, + insetsTop: insets.top, + insetsBottom: insets.bottom, + }); + }, [width, isCollapsed, activeTab, unreadCount, insets]); + const navItems: NavItem[] = [ { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount }, @@ -461,12 +478,13 @@ interface ResponsiveTabNavigatorProps { function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { const navigation = useNavigation>(); const route = useRoute>(); - const { isMobile, isTablet, isDesktop } = useResponsive(); + const { isMobile, isTablet, isDesktop, width, height } = useResponsive(); const insets = useSafeAreaInsets(); const [activeTab, setActiveTab] = useState('HomeTab'); const [isCollapsed, setIsCollapsed] = useState(false); const messageUnreadCount = useTotalUnreadCount(); + // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 useEffect(() => { messageManager.initialize(); @@ -483,54 +501,62 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { // 计算侧边栏宽度 const sidebarWidth = useCallback(() => { + if (isMobile) return 0; // 移动端不显示侧边栏 if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH; if (isDesktop) return SIDEBAR_WIDTH_DESKTOP; if (isTablet) return SIDEBAR_WIDTH_TABLET; - return 0; - }, [isCollapsed, isDesktop, isTablet]); + // 默认返回桌面宽度,避免返回 0 导致导航栏消失 + return SIDEBAR_WIDTH_DESKTOP; + }, [isCollapsed, isDesktop, isTablet, isMobile]); // 切换 Tab const handleTabChange = useCallback((tab: TabName) => { setActiveTab(tab); - if (Platform.OS === 'web') { + // 在大屏幕模式下,通过导航切换 Tab + if (!isMobile) { let tabTarget: NavigatorScreenParams; if (tab === 'HomeTab') { tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } }; } else if (tab === 'MessageTab') { tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } }; + } else if (tab === 'ScheduleTab') { + tabTarget = { screen: 'ScheduleTab', params: { screen: 'Schedule' } }; } else { tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } }; } navigation.navigate('Main', tabTarget); } - }, [navigation]); + }, [navigation, isMobile]); // 切换折叠状态 const handleToggleCollapse = useCallback(() => { setIsCollapsed(prev => !prev); }, []); - // 渲染当前 Tab 的内容 + // 移动端:使用底部 Tab 导航 + if (isMobile) { + // 使用简化的移动端导航,避免 React Navigation 状态恢复问题 + return ; + } + + // 渲染当前 Tab 的内容(仅在大屏模式下使用) + // 注意:直接渲染屏幕组件,不使用 Stack Navigators + // 这样可以避免 React Navigation 状态恢复问题 const renderTabContent = () => { switch (activeTab) { case 'HomeTab': - return ; + return ; case 'MessageTab': - return ; + return ; case 'ScheduleTab': - return ; + return ; case 'ProfileTab': - return ; + return ; default: - return ; + return ; } }; - // 移动端:使用底部 Tab 导航 - if (isMobile) { - return ; - } - // 平板/桌面端:使用侧边导航 const currentSidebarWidth = sidebarWidth(); diff --git a/src/navigation/MainNavigator.tsx.bak b/src/navigation/MainNavigator.tsx.bak new file mode 100644 index 0000000..a60fced --- /dev/null +++ b/src/navigation/MainNavigator.tsx.bak @@ -0,0 +1,1126 @@ +/** + * 主导航组件 + * 配置底部Tab导航和Stack导航 + * 根据登录状态自动切换AuthStack和MainStack + * 支持响应式布局:移动端底部导航,平板/桌面端侧边导航 + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + StyleSheet, + View, + ActivityIndicator, + TouchableOpacity, + Animated, + ScrollView, + Text as RNText, + Platform, +} from 'react-native'; +import { + NavigationContainer, + LinkingOptions, + NavigatorScreenParams, + RouteProp, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useTheme } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; + +import { colors, shadows } from '../theme'; +import { useAuthStore, useTotalUnreadCount } from '../stores'; +import { messageManager } from '../stores'; +import { useResponsive } from '../hooks'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { + RootStackParamList, + MainTabParamList, + HomeStackParamList, + MessageStackParamList, + ScheduleStackParamList, + ProfileStackParamList, + AuthStackParamList, +} from './types'; + +// ==================== 导入屏幕组件 ==================== +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'; +import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; + +// ==================== Stack Navigators ==================== +const RootStack = createNativeStackNavigator(); +const AuthStack = createNativeStackNavigator(); +const HomeStack = createNativeStackNavigator(); +const MessageStack = createNativeStackNavigator(); +const ScheduleStack = createNativeStackNavigator(); +const ProfileStack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +// ==================== 导航栏宽度常量 ==================== +const SIDEBAR_WIDTH_DESKTOP = 240; +const SIDEBAR_WIDTH_TABLET = 200; +const SIDEBAR_COLLAPSED_WIDTH = 72; +const MOBILE_TAB_FLOATING_MARGIN = 12; + +// ==================== 持久化存储键 ==================== +const LAST_ACTIVE_TAB_KEY = 'last_active_tab'; + +// ==================== 导航项类型 ==================== +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; + +type IconName = React.ComponentProps['name']; + +interface NavItem { + name: TabName; + label: string; + icon: IconName; + iconOutline: IconName; + badge?: number; +} + +// ==================== AuthStack导航 ==================== +function AuthStackNavigator() { + return ( + + + + + + ); +} + +// ==================== 首页Stack导航 ==================== +function HomeStackNavigator() { + const paperTheme = useTheme(); + + return ( + + + + + ); +} + +// ==================== 消息Stack导航 ==================== +function MessageStackNavigator() { + return ( + + + + + + ); +} + +// ==================== 个人中心Stack导航 ==================== +function ProfileStackNavigator() { + return ( + + + + + + + + + + + ); +} + +// ==================== 侧边导航栏组件 ==================== +interface SidebarProps { + activeTab: TabName; + onTabChange: (tab: TabName) => void; + unreadCount: number; + isCollapsed: boolean; + onToggleCollapse: () => void; + width: number; +} + +function Sidebar({ + activeTab, + onTabChange, + unreadCount, + isCollapsed, + onToggleCollapse, + width, +}: SidebarProps) { + const insets = useSafeAreaInsets(); + + // 调试日志 + useEffect(() => { + console.log('[Debug Sidebar]', { + width, + isCollapsed, + activeTab, + unreadCount, + insetsTop: insets.top, + insetsBottom: insets.bottom, + }); + }, [width, isCollapsed, activeTab, unreadCount, insets]); + + const navItems: NavItem[] = [ + { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, + { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount }, + { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, + { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, + ]; + + return ( + + {/* Logo/品牌区域 */} + + {!isCollapsed && ( + + + + 胡萝卜BBS + + + )} + {isCollapsed && ( + + )} + + + {/* 导航项 */} + + {navItems.map((item) => { + const isActive = activeTab === item.name; + const showBadge = !!(item.badge && item.badge > 0); + + return ( + onTabChange(item.name)} + activeOpacity={0.7} + > + + + {showBadge && ( + + + {item.badge! > 99 ? '99+' : item.badge} + + + )} + + {!isCollapsed && ( + + {item.label} + + )} + + ); + })} + + + {/* 底部折叠按钮 */} + + + + + ); +} + +// ==================== Text 组件 ==================== +function Text({ style, children, numberOfLines }: { style?: any; children: React.ReactNode; numberOfLines?: number }) { + return ( + + {children} + + ); +} + +// ==================== 课程表Stack导航 ==================== +function ScheduleStackNavigator() { + return ( + + + + + ); +} + +// ==================== 响应式 Tab Navigator ==================== +interface ResponsiveTabNavigatorProps { + children?: React.ReactNode; +} + +function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { + const navigation = useNavigation>(); + const route = useRoute>(); + const { isMobile, isTablet, isDesktop, width, height } = useResponsive(); + const insets = useSafeAreaInsets(); + const [activeTab, setActiveTab] = useState('HomeTab'); + const [isCollapsed, setIsCollapsed] = useState(false); + const messageUnreadCount = useTotalUnreadCount(); + + // 调试日志:跟踪关键变量 + useEffect(() => { + console.log('[Debug ResponsiveTabNavigator]', { + width, + height, + isMobile, + isTablet, + isDesktop, + isCollapsed, + sidebarWidth: isCollapsed ? 72 : (isDesktop ? 240 : (isTablet ? 200 : 0)) + }); + }, [width, height, isMobile, isTablet, isDesktop, isCollapsed]); + + // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 + useEffect(() => { + messageManager.initialize(); + }, []); + + // 同步 URL 中的 Main 子路由到桌面侧边栏激活态 + useEffect(() => { + const targetTab = route.params?.screen; + if (!targetTab) return; + if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ScheduleTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) { + setActiveTab(targetTab); + } + }, [route.params?.screen, activeTab]); + + // 计算侧边栏宽度 + const sidebarWidth = useCallback(() => { + if (isMobile) return 0; // 移动端不显示侧边栏 + if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH; + if (isDesktop) return SIDEBAR_WIDTH_DESKTOP; + if (isTablet) return SIDEBAR_WIDTH_TABLET; + // 默认返回桌面宽度,避免返回 0 导致导航栏消失 + return SIDEBAR_WIDTH_DESKTOP; + }, [isCollapsed, isDesktop, isTablet, isMobile]); + + // 切换 Tab + const handleTabChange = useCallback((tab: TabName) => { + setActiveTab(tab); + // 在大屏幕模式下,通过导航切换 Tab + if (!isMobile) { + let tabTarget: NavigatorScreenParams; + if (tab === 'HomeTab') { + tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } }; + } else if (tab === 'MessageTab') { + tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } }; + } else if (tab === 'ScheduleTab') { + tabTarget = { screen: 'ScheduleTab', params: { screen: 'Schedule' } }; + } else { + tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } }; + } + navigation.navigate('Main', tabTarget); + } + }, [navigation, isMobile]); + + // 切换折叠状态 + const handleToggleCollapse = useCallback(() => { + setIsCollapsed(prev => !prev); + }, []); + + // 渲染当前 Tab 的内容 + const renderTabContent = () => { + switch (activeTab) { + case 'HomeTab': + return ; + case 'MessageTab': + return ; + case 'ScheduleTab': + return ; + case 'ProfileTab': + return ; + default: + return ; + } + }; + + // 移动端:使用底部 Tab 导航 + if (isMobile) { + return ; + } + + // 平板/桌面端:使用侧边导航 + const currentSidebarWidth = sidebarWidth(); + + return ( + + {/* 侧边导航栏 */} + + + {/* 主内容区域 */} + + {renderTabContent()} + + + ); +} + +// ==================== 底部Tab导航(移动端)==================== +function MainTabNavigator() { + const insets = useSafeAreaInsets(); + const messageUnreadCount = useTotalUnreadCount(); + + // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 + useEffect(() => { + messageManager.initialize(); + }, []); + + return ( + + ( + + + + ), + }} + /> + 0 ? (messageUnreadCount > 99 ? '99+' : messageUnreadCount) : undefined, + tabBarIcon: ({ color, size, focused }) => ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + + ); +} + +// ==================== 根导航 ==================== +export default function MainNavigator() { + const { isAuthenticated, fetchCurrentUser } = useAuthStore(); + const [initializing, setInitializing] = useState(true); + + const linking: LinkingOptions = { + prefixes: [], + 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', + }, + }, + }; + + // 初始化时检查登录状态 + useEffect(() => { + const initAuth = async () => { + await fetchCurrentUser(); + setInitializing(false); + }; + initAuth(); + }, [fetchCurrentUser]); + + // 加载中显示 + if (initializing) { + return ( + + + + ); + } + + return ( + + + {/* 根据登录状态显示不同Stack */} + {isAuthenticated ? ( + <> + + + + + + + + + + + + + + + ) : ( + <> + + {/* 未登录时也可以查看帖子详情 */} + + + + )} + + + ); +} + +// ==================== 样式 ==================== +const styles = StyleSheet.create({ + // 响应式容器 + responsiveContainer: { + flex: 1, + flexDirection: 'row', + backgroundColor: colors.background.default, + }, + + // 侧边栏样式 + sidebar: { + backgroundColor: colors.background.paper, + borderRightWidth: 1, + borderRightColor: colors.divider, + flexDirection: 'column', + ...shadows.md, + }, + sidebarHeader: { + paddingHorizontal: 16, + paddingVertical: 20, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + alignItems: 'center', + justifyContent: 'center', + }, + logoContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + logoText: { + fontSize: 20, + fontWeight: '700', + color: colors.primary.main, + marginLeft: 12, + }, + sidebarContent: { + flex: 1, + paddingTop: 8, + }, + sidebarItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 8, + marginVertical: 4, + borderRadius: 12, + }, + sidebarItemActive: { + backgroundColor: `${colors.primary.main}15`, + }, + sidebarItemCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + sidebarIconContainer: { + position: 'relative', + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + sidebarLabel: { + fontSize: 15, + fontWeight: '500', + color: colors.text.secondary, + marginLeft: 12, + flex: 1, + }, + sidebarLabelActive: { + color: colors.primary.main, + fontWeight: '600', + }, + collapseButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 12, + borderTopWidth: 1, + borderTopColor: colors.divider, + }, + collapseButtonCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + + // 徽章样式 + 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', + }, + + // 主内容区域 + mainContent: { + flex: 1, + backgroundColor: colors.background.default, + }, + + // 底部 Tab 样式 + tabIconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 38, + height: 30, + borderRadius: 15, + }, + tabIconActive: { + backgroundColor: `${colors.primary.main}20`, + transform: [{ translateY: -1 }], + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, +}); diff --git a/src/navigation/MobileTabNavigatorWithDelay.tsx b/src/navigation/MobileTabNavigatorWithDelay.tsx new file mode 100644 index 0000000..5eb1ac3 --- /dev/null +++ b/src/navigation/MobileTabNavigatorWithDelay.tsx @@ -0,0 +1,545 @@ +/** + * 移动端 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; +}; + +export type MessageStackParamList = { + MessageList: undefined; + Notifications: undefined; + PrivateChatInfo: { conversationId: string; userId: string }; +}; + +export type ScheduleStackParamList = { + Schedule: undefined; + CourseDetail: { courseId: string }; +}; + +export type ProfileStackParamList = { + Profile: undefined; + Settings: undefined; + EditProfile: undefined; + AccountSecurity: undefined; + MyPosts: undefined; + Bookmarks: undefined; + NotificationSettings: undefined; + BlockedUsers: undefined; +}; + +// ==================== Stack Navigators ==================== +const HomeStack = createNativeStackNavigator(); +const MessageStack = createNativeStackNavigator(); +const ScheduleStack = createNativeStackNavigator(); +const ProfileStack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +// ==================== 常量 ==================== +const MOBILE_TAB_FLOATING_MARGIN = 12; +const INITIALIZATION_DELAY = 500; // ms,增加延迟时间确保 React Navigation 完全初始化 +const MAX_RETRY_ATTEMPTS = 5; +const RETRY_DELAY = 500; // ms + +// ==================== Stack Navigator 组件 ==================== + +function HomeStackNavigatorComponent() { + return ( + + + + + ); +} + +function MessageStackNavigatorComponent() { + return ( + + + + + + ); +} + +function ScheduleStackNavigatorComponent() { + return ( + + + + + ); +} + +function ProfileStackNavigatorComponent() { + return ( + + + + + + + + + + + ); +} + +// ==================== 主组件:MobileTabNavigatorWithDelay ==================== + +export function MobileTabNavigatorWithDelay() { + const insets = useSafeAreaInsets(); + const messageUnreadCount = useTotalUnreadCount(); + const [isReady, setIsReady] = useState(false); + const [hasError, setHasError] = useState(false); + const initAttemptRef = useRef(0); + + // 初始化 MessageManager + useEffect(() => { + messageManager.initialize(); + }, []); + + // 延迟初始化 + useEffect(() => { + let timeoutId: NodeJS.Timeout; + let isMounted = true; + + const initializeNavigator = async () => { + try { + initAttemptRef.current += 1; + console.log( + `[MobileTabNavigator] Initialization attempt ${initAttemptRef.current}/${MAX_RETRY_ATTEMPTS}` + ); + + // 延迟初始化 + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, INITIALIZATION_DELAY); + }); + + if (!isMounted) return; + + // 后台检测 + if (Platform.OS !== 'web' && AppState.currentState === 'background') { + console.log('[MobileTabNavigator] App in background, delaying...'); + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 300); + }); + } + + if (!isMounted) return; + + setIsReady(true); + setHasError(false); + console.log('[MobileTabNavigator] Initialization successful'); + } catch (error) { + console.error('[MobileTabNavigator] Initialization error:', error); + + if (!isMounted) return; + + if (initAttemptRef.current < MAX_RETRY_ATTEMPTS) { + console.log('[MobileTabNavigator] Retrying...'); + timeoutId = setTimeout(initializeNavigator, RETRY_DELAY); + } else { + setHasError(true); + } + } + }; + + initializeNavigator(); + + return () => { + isMounted = false; + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + // 渲染加载状态 + if (!isReady && !hasError) { + return ( + + + + ); + } + + // 渲染错误状态(带重试按钮) + if (hasError) { + return ( + + + + + ); + } + + // 渲染 Tab Navigator + return ( + + ( + + + + ), + }} + /> + 0 + ? messageUnreadCount > 99 + ? '99+' + : messageUnreadCount + : undefined, + tabBarIcon: ({ color, size, focused }) => ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + + ); +} + +// ==================== 样式 ==================== +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + padding: 20, + }, + retryIndicator: { + marginTop: 16, + }, + tabIconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 38, + height: 30, + borderRadius: 15, + }, + tabIconActive: { + backgroundColor: `${colors.primary.main}20`, + transform: [{ translateY: -1 }], + }, +}); diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx new file mode 100644 index 0000000..4045aa6 --- /dev/null +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -0,0 +1,282 @@ +/** + * 简单的移动端 Tab Navigator + * + * 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现 + * 这样可以完全避免 React Navigation 状态恢复的问题 + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Text, + Dimensions, +} from 'react-native'; +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, SearchScreen } from '../screens/home'; +import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; +import { + MessageListScreen, + NotificationsScreen, + PrivateChatInfoScreen, +} from '../screens/message'; +import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; + +// ==================== 类型定义 ==================== +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; + +// ==================== 常量 ==================== +const TAB_BAR_HEIGHT = 64; +const TAB_BAR_MARGIN = 14; +const TAB_BAR_FLOATING_MARGIN = 12; + +// ==================== 组件 ==================== + +/** + * 简化的 Stack Navigator 包装 + */ +function SimpleStackNavigator({ + children, + screenName +}: { + children: React.ReactNode; + screenName: string; +}) { + return ( + + {children} + + ); +} + +/** + * 主组件:SimpleMobileTabNavigator + */ +export function SimpleMobileTabNavigator() { + const insets = useSafeAreaInsets(); + const messageUnreadCount = useTotalUnreadCount(); + const [activeTab, setActiveTab] = useState('HomeTab'); + + // 初始化 MessageManager + useEffect(() => { + messageManager.initialize(); + }, []); + + // 处理 Tab 切换 + const handleTabPress = useCallback((tabName: TabName) => { + setActiveTab(tabName); + }, []); + + // 渲染当前 Tab 的内容 + const renderTabContent = () => { + switch (activeTab) { + case 'HomeTab': + return ; + case 'MessageTab': + return ; + case 'ScheduleTab': + return ; + case 'ProfileTab': + return ; + default: + return ; + } + }; + + // 渲染 Tab Bar 图标 + const renderTabIcon = (tabName: TabName, isActive: boolean) => { + const iconColor = isActive ? colors.primary.main : colors.text.secondary; + const iconSize = isActive ? 26 : 24; + + switch (tabName) { + case 'HomeTab': + return ( + + ); + case 'MessageTab': + return ( + + ); + case 'ScheduleTab': + return ( + + ); + case 'ProfileTab': + return ( + + ); + default: + return null; + } + }; + + // 渲染 Tab Bar 标签 + const renderTabLabel = (tabName: TabName, isActive: boolean) => { + const labels: Record = { + HomeTab: '首页', + MessageTab: '消息', + ScheduleTab: '课表', + ProfileTab: '我的', + }; + + return ( + + {labels[tabName]} + + ); + }; + + return ( + + {/* 内容区域 */} + + {renderTabContent()} + + + {/* Tab Bar */} + + {(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map( + (tabName) => { + const isActive = activeTab === tabName; + const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0; + + return ( + handleTabPress(tabName)} + activeOpacity={0.7} + > + + {renderTabIcon(tabName, isActive)} + {showBadge && ( + + + {messageUnreadCount > 99 ? '99+' : messageUnreadCount} + + + )} + + {renderTabLabel(tabName, isActive)} + + ); + } + )} + + + ); +} + +// ==================== 样式 ==================== +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + stackContainer: { + flex: 1, + }, + content: { + flex: 1, + marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2, + }, + tabBar: { + position: 'absolute', + left: TAB_BAR_MARGIN, + right: TAB_BAR_MARGIN, + height: TAB_BAR_HEIGHT, + backgroundColor: colors.background.paper, + borderRadius: 24, + borderTopWidth: 1, + borderTopColor: `${colors.divider}88`, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + paddingHorizontal: 8, + ...shadows.lg, + }, + tabItem: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 6, + borderRadius: 18, + }, + tabItemActive: { + backgroundColor: `${colors.primary.main}15`, + }, + tabIconContainer: { + position: 'relative', + width: 38, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 15, + }, + tabLabel: { + fontSize: 12, + fontWeight: '600', + marginTop: -2, + letterSpacing: 0.2, + color: colors.text.secondary, + }, + tabLabelActive: { + color: colors.primary.main, + }, + badge: { + position: 'absolute', + top: -2, + right: -2, + backgroundColor: colors.error.main, + borderRadius: 10, + minWidth: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 4, + }, + badgeText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, +}); diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index a5e0984..711bcfb 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -1,9 +1,10 @@ /** - * 登录页 LoginScreen(响应式适配) + * 登录页 LoginScreen(响应式适配 - 分栏布局) * 胡萝卜BBS - 用户登录 - * 现代化设计 - 渐变背景 + 动画效果 - * 登录表单在桌面端居中显示,限制最大宽度 - * 支持横屏模式下的布局调整 + * + * 布局规则: + * - 屏幕宽度 < 768px:单栏布局,橙色渐变背景 + * - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰 */ import React, { useState, useEffect, useRef } from 'react'; @@ -18,6 +19,7 @@ import { ScrollView, ActivityIndicator, Animated, + StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -28,11 +30,13 @@ import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { useAuthStore } from '../../stores'; import { RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveValue } from '../../hooks'; -import { ResponsiveContainer } from '../../components/common'; import { showPrompt } from '../../services/promptService'; type LoginNavigationProp = NativeStackNavigationProp; +// 分栏布局断点 +const SPLIT_BREAKPOINT = 768; + export const LoginScreen: React.FC = () => { const navigation = useNavigation(); const login = useAuthStore((state) => state.login); @@ -40,7 +44,10 @@ export const LoginScreen: React.FC = () => { const setStoreError = useAuthStore((state) => state.setError); // 响应式布局 - const { isWideScreen, isLandscape, isDesktop, width } = useResponsive(); + const { isLandscape, width } = useResponsive(); + + // 是否使用分栏布局 + const isSplitLayout = width >= SPLIT_BREAKPOINT; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -54,11 +61,23 @@ export const LoginScreen: React.FC = () => { const scaleAnim = useRef(new Animated.Value(0.9)).current; const inputFadeAnim = useRef(new Animated.Value(0)).current; - // 响应式值 - const logoSize = useResponsiveValue({ xs: 64, sm: 72, md: 80, lg: 96, xl: 110 }); - const logoContainerSize = useResponsiveValue({ xs: 110, sm: 120, md: 130, lg: 150, xl: 170 }); - const appNameSize = useResponsiveValue({ xs: 28, sm: 30, md: 32, lg: 36, xl: 40 }); - const formMaxWidth = isDesktop ? 480 : isWideScreen ? 420 : undefined; + // 响应式值 - 大屏模式单独放大Logo尺寸,更显大气 + const logoSize = useResponsiveValue({ + xs: 64, sm: 72, md: 80, + lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大 + xl: isSplitLayout ? 160 : 110 + }); + const logoContainerSize = useResponsiveValue({ + xs: 110, sm: 120, md: 130, + lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大 + xl: isSplitLayout ? 220 : 170 + }); + const appNameSize = useResponsiveValue({ + xs: 28, sm: 30, md: 32, + lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大 + xl: isSplitLayout ? 72 : 40 + }); + const formMaxWidth = isSplitLayout ? 420 : undefined; // 启动入场动画 useEffect(() => { @@ -105,7 +124,7 @@ export const LoginScreen: React.FC = () => { return true; }; - // store error 同步到本地,方便在输入时清除 + // store error 同步到本地 useEffect(() => { if (storeError) { setErrorMsg(storeError); @@ -126,8 +145,6 @@ export const LoginScreen: React.FC = () => { try { const success = await login({ username, password }); if (!success) { - // authStore 已将错误写入 storeError,useEffect 会同步到 errorMsg - // 若 storeError 为空则显示通用提示 if (!useAuthStore.getState().error) { setErrorMsg('登录失败,请稍后重试'); } @@ -144,39 +161,66 @@ export const LoginScreen: React.FC = () => { navigation.navigate('Register' as any); }; + // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 + const renderLeftPanel = () => ( + + {/* 胡萝卜图标 - 靠上显示 */} + + + + + {/* 品牌名称 - 优化大屏排版 */} + 胡萝卜 + 发现有趣的内容,结识志同道合的朋友 + + {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} + + {isSplitLayout ? ( + // 大屏模式:水平排列 + 大幅放大图标 + + + + + + ) : ( + // 小屏模式:保持原有横向排列 + + + + + + )} + + + ); + // 渲染表单内容 const renderFormContent = () => ( - <> - {/* Logo和标题区域 */} - - - - - 胡萝卜 - 发现有趣的内容,结识志同道合的朋友 - - - {/* 表单卡片 */} + { {/* 用户名输入框 */} - - + { {/* 密码输入框 */} - - + { {errorMsg} @@ -290,13 +322,10 @@ export const LoginScreen: React.FC = () => { activeOpacity={0.8} > {loading ? ( @@ -319,13 +348,48 @@ export const LoginScreen: React.FC = () => { 立即注册 - + ); - return ( - + // 渲染分栏布局 + const renderSplitLayout = () => ( + + {/* 左侧橙色面板 */} + {/* 装饰性背景元素 */} + + + + + + {renderLeftPanel()} + + + + {/* 右侧中性灰面板 */} + + + + {/* 核心修复:用View包裹,确保垂直+水平居中 */} + + {renderFormContent()} + + + + + ); + + // 渲染单栏布局 + const renderSingleLayout = () => ( + + + { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView} > - {isWideScreen ? ( - - - {/* 装饰性背景元素 */} - - - - {renderFormContent()} - - - ) : ( - + {/* 装饰性背景元素 */} + + + + {/* 头部 */} + - {/* 装饰性背景元素 */} - - - - {renderFormContent()} - - )} + + + + 胡萝卜 + 发现有趣的内容,结识志同道合的朋友 + + + {/* 表单 */} + {renderFormContent()} + ); + + return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); }; const styles = StyleSheet.create({ @@ -383,11 +456,17 @@ const styles = StyleSheet.create({ keyboardView: { flex: 1, }, + // 单栏布局内容 scrollContent: { flexGrow: 1, paddingHorizontal: spacing.xl, paddingVertical: spacing['2xl'], }, + scrollContentCentered: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }, scrollContentLandscape: { paddingVertical: spacing.lg, }, @@ -429,6 +508,15 @@ const styles = StyleSheet.create({ borderColor: 'rgba(255,255,255,0.4)', ...shadows.md, }, + // 大屏模式Logo容器样式 - 增加阴影和内边距,更立体 + splitLogoContainer: { + borderWidth: 3, + borderColor: 'rgba(255,255,255,0.5)', + ...shadows.lg, + }, + logoContainerTop: { + marginTop: spacing['3xl'], + }, appName: { fontWeight: '800', color: '#FFFFFF', @@ -437,20 +525,54 @@ const styles = StyleSheet.create({ textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, }, + // 大屏模式标题样式 - 大幅放大,增加间距和文字效果 + splitAppName: { + fontSize: 72, // 大幅放大主标题 + marginBottom: spacing['2xl'], + textShadowOffset: { width: 0, height: 4 }, + textShadowRadius: 8, + letterSpacing: 4, // 增加字间距,更显大气 + fontWeight: '900', + }, subtitle: { fontSize: fontSizes.md, color: 'rgba(255,255,255,0.9)', textAlign: 'center', paddingHorizontal: spacing.xl, }, - // 表单卡片 + // 大屏模式副标题样式 - 大幅放大 + splitSubtitle: { + fontSize: 24, // 放大副标题 + marginBottom: spacing['6xl'], // 增加底部间距 + lineHeight: 36, + paddingHorizontal: spacing['4xl'], + letterSpacing: 1.5, + fontWeight: '500', + }, + // 表单卡片(基础样式,无阴影) formCard: { backgroundColor: colors.background.paper, borderRadius: borderRadius['2xl'], padding: spacing['2xl'], marginBottom: spacing.xl, + }, + // 小屏模式:原有默认阴影 + normalFormCardShadow: { ...shadows.md, }, + // 大屏模式:专用全向阴影 + splitFormCardShadow: { + // iOS 全向柔和阴影 + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 12, + // Android 高程 + 轻微边框(模拟顶部阴影) + elevation: 8, + // 新增:极细微的顶部边框,强化区分度 + borderTopWidth: 1, + borderTopColor: 'rgba(0,0,0,0.05)', + }, formTitle: { fontSize: fontSizes['2xl'], fontWeight: '700', @@ -471,9 +593,6 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, height: 56, }, - inputWrapperWide: { - height: 60, - }, inputIcon: { marginRight: spacing.sm, }, @@ -482,9 +601,6 @@ const styles = StyleSheet.create({ fontSize: fontSizes.md, color: colors.text.primary, }, - inputWide: { - fontSize: fontSizes.lg, - }, clearButton: { padding: spacing.xs, }, @@ -495,23 +611,23 @@ const styles = StyleSheet.create({ forgotPasswordText: { fontSize: fontSizes.sm, color: colors.primary.main, - fontWeight: '500', + fontWeight: '600', }, errorBox: { flexDirection: 'row', alignItems: 'flex-start', - backgroundColor: '#FFEBEE', + backgroundColor: colors.accent.light, borderRadius: borderRadius.md, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, marginBottom: spacing.md, borderLeftWidth: 3, - borderLeftColor: '#D32F2F', + borderLeftColor: colors.accent.main, }, errorText: { flex: 1, fontSize: fontSizes.sm, - color: '#D32F2F', + color: colors.accent.dark, lineHeight: 20, }, loginButton: { @@ -524,9 +640,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - loginButtonGradientWide: { - height: 56, - }, loginButtonDisabled: { opacity: 0.7, }, @@ -540,20 +653,108 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', - marginTop: 'auto', paddingTop: spacing.xl, }, footerText: { fontSize: fontSizes.md, - color: 'rgba(255,255,255,0.9)', + color: colors.text.primary, }, registerLink: { fontSize: fontSizes.md, - color: '#FFFFFF', + color: colors.primary.main, fontWeight: '700', marginLeft: spacing.xs, textDecorationLine: 'underline', }, + // ========== 分栏布局样式 ========== + splitContainer: { + flex: 1, + flexDirection: 'row', + }, + leftPanelContainer: { + flex: 1, // 50% + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + leftPanelSafeArea: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边 + }, + leftPanel: { + alignItems: 'center', + paddingHorizontal: spacing['2xl'], + }, + // 大屏模式左侧面板布局 + splitLeftPanel: { + width: '100%', + justifyContent: 'center', + paddingVertical: spacing['6xl'], + }, + leftPanelDecor: { + position: 'absolute', + bottom: spacing['4xl'], + left: 0, + right: 0, + alignItems: 'center', + }, + leftPanelDecorBottom: { + marginTop: spacing['2xl'], + }, + // 大屏模式装饰元素布局 + splitLeftPanelDecorBottom: { + marginTop: spacing['6xl'], + width: '100%', + alignItems: 'center', + }, + // 大屏模式装饰元素水平排列 + splitDecorRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + width: '100%', + gap: spacing['2xl'], // 增加图标间距 + }, + // 大屏模式装饰图标样式 + splitDecorIcon: { + transform: [{ scale: 1.1 }], + opacity: 0.9, + }, + decorRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + width: '80%', + }, + rightPanelContainer: { + flex: 1, // 50% + backgroundColor: colors.background.paper, + }, + rightPanelSafeArea: { + flex: 1, + // 移除左右内边距,交给子容器控制 + }, + // 分栏布局右侧内容包裹器(核心修复) + splitRightContentWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + // 新增:左右内边距,保证表单和边缘有间距 + paddingHorizontal: spacing['2xl'], + }, + // 表单内容包裹器(区分大屏/小屏) + splitFormWrapper: { + // 关键修改:设置最大宽度 + 自适应宽度,实现水平居中 + maxWidth: 420, + width: '100%', + }, + singleFormWrapper: { + flexGrow: 1, + width: '100%', + // 移除这里的阴影,避免重复 + }, }); -export default LoginScreen; +export default LoginScreen; \ No newline at end of file diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index 43820ef..12f9794 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -1,9 +1,10 @@ /** - * 注册页 RegisterScreen(响应式适配) + * 注册页 RegisterScreen(响应式适配 - 分栏布局) * 胡萝卜BBS - 用户注册 - * 现代化设计 - 渐变背景 + 动画效果 - * 注册表单在桌面端居中显示,限制最大宽度 - * 支持横屏模式下的布局调整 + * + * 布局规则: + * - 屏幕宽度 < 768px:单栏布局,橙色渐变背景 + * - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰 */ import React, { useState, useEffect, useRef } from 'react'; @@ -18,6 +19,7 @@ import { ScrollView, ActivityIndicator, Animated, + StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -29,17 +31,22 @@ import { authService, resolveAuthApiError } from '../../services/authService'; import { useAuthStore } from '../../stores'; import { RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveValue } from '../../hooks'; -import { ResponsiveContainer } from '../../components/common'; import { showPrompt } from '../../services/promptService'; type RegisterNavigationProp = NativeStackNavigationProp; +// 分栏布局断点 +const SPLIT_BREAKPOINT = 768; + export const RegisterScreen: React.FC = () => { const navigation = useNavigation(); const register = useAuthStore((state) => state.register); // 响应式布局 - const { isWideScreen, isLandscape, isDesktop } = useResponsive(); + const { isLandscape, width } = useResponsive(); + + // 是否使用分栏布局 + const isSplitLayout = width >= SPLIT_BREAKPOINT; const [username, setUsername] = useState(''); const [nickname, setNickname] = useState(''); @@ -61,11 +68,23 @@ export const RegisterScreen: React.FC = () => { const scaleAnim = useRef(new Animated.Value(0.95)).current; const inputFadeAnim = useRef(new Animated.Value(0)).current; - // 响应式值 - const iconSize = useResponsiveValue({ xs: 48, sm: 52, md: 56, lg: 64, xl: 72 }); - const iconContainerSize = useResponsiveValue({ xs: 80, sm: 88, md: 96, lg: 108, xl: 120 }); - const titleSize = useResponsiveValue({ xs: 24, sm: 26, md: 28, lg: 30, xl: 32 }); - const formMaxWidth = isDesktop ? 520 : isWideScreen ? 480 : undefined; + // 响应式值 - 大屏模式单独放大图标尺寸,更显大气 + const iconSize = useResponsiveValue({ + xs: 48, sm: 52, md: 56, + lg: isSplitLayout ? 120 : 64, // 大屏模式图标进一步放大 + xl: isSplitLayout ? 140 : 72 + }); + const iconContainerSize = useResponsiveValue({ + xs: 80, sm: 88, md: 96, + lg: isSplitLayout ? 180 : 108, // 大屏模式图标容器进一步放大 + xl: isSplitLayout ? 200 : 120 + }); + const titleSize = useResponsiveValue({ + xs: 24, sm: 26, md: 28, + lg: isSplitLayout ? 60 : 30, // 大屏模式标题文字大幅放大 + xl: isSplitLayout ? 72 : 32 + }); + const formMaxWidth = isSplitLayout ? 480 : undefined; // 启动入场动画 useEffect(() => { @@ -219,64 +238,93 @@ export const RegisterScreen: React.FC = () => { // 跳转到登录页 const handleGoToLogin = () => { - navigation.goBack(); + navigation.navigate('Login' as any); }; + // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 + const renderLeftPanel = () => ( + + {/* 用户图标 - 靠上显示 */} + + + + + {/* 品牌名称 - 优化大屏排版 */} + 创建账号 + 加入胡萝卜,开始你的旅程 + + {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} + + {isSplitLayout ? ( + // 大屏模式:水平排列 + 大幅放大图标 + + + + + + ) : ( + // 小屏模式:保持原有横向排列 + + + + + + )} + + + ); + // 渲染表单内容 const renderFormContent = () => ( - <> - {/* 标题区域 */} - - - - - 创建账号 - 加入胡萝卜,开始你的旅程 - - - {/* 表单卡片 */} - + + 注册账号 + {/* 用户名输入框 */} - { - { - { {/* 邮箱验证码 */} - + { style={styles.inputIcon} /> { - { - { - { activeOpacity={0.8} > {loading ? ( @@ -577,24 +625,60 @@ export const RegisterScreen: React.FC = () => { {/* 底部登录提示 */} - - 已有账号? + 已有账号? - 立即登录 + 立即登录 - + ); - return ( - + // 渲染分栏布局 + const renderSplitLayout = () => ( + + {/* 左侧橙色面板 */} + {/* 装饰性背景元素 */} + + + + + + {renderLeftPanel()} + + + + {/* 右侧中性灰面板 */} + + + + {/* 核心修复:用View包裹,确保垂直+水平居中 */} + + {renderFormContent()} + + + + + ); + + // 渲染单栏布局 + const renderSingleLayout = () => ( + + + { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView} > - {isWideScreen ? ( - - - {/* 装饰性背景元素 */} - - - - {renderFormContent()} - - - ) : ( - + {/* 装饰性背景元素 */} + + + + {/* 头部 */} + - {/* 装饰性背景元素 */} - - - - {renderFormContent()} - - )} + + + + 创建账号 + 加入胡萝卜,开始你的旅程 + + + {/* 表单 */} + {renderFormContent()} + ); + + return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); }; const styles = StyleSheet.create({ @@ -827,10 +920,16 @@ const styles = StyleSheet.create({ paddingTop: spacing.md, paddingBottom: spacing.md, }, + splitFooterSection: { + paddingTop: spacing.xl, + }, footerText: { fontSize: fontSizes.md, color: 'rgba(255,255,255,0.9)', }, + splitFooterText: { + color: colors.text.primary, + }, loginLink: { fontSize: fontSizes.md, color: '#FFFFFF', @@ -838,6 +937,141 @@ const styles = StyleSheet.create({ marginLeft: spacing.xs, textDecorationLine: 'underline', }, + splitLoginLink: { + color: colors.primary.main, + }, + // ========== 分栏布局样式 ========== + splitContainer: { + flex: 1, + flexDirection: 'row', + }, + leftPanelContainer: { + flex: 1, // 50% + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + leftPanelSafeArea: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边 + }, + leftPanel: { + alignItems: 'center', + paddingHorizontal: spacing['2xl'], + }, + // 大屏模式左侧面板布局 + splitLeftPanel: { + width: '100%', + justifyContent: 'center', + paddingVertical: 120, + }, + // 大屏模式图标容器样式 - 增加阴影和内边距,更立体 + splitIconContainer: { + borderWidth: 3, + borderColor: 'rgba(255,255,255,0.5)', + ...shadows.lg, + }, + iconContainerTop: { + marginTop: spacing['3xl'], + }, + // 大屏模式标题样式 - 大幅放大,增加间距和文字效果 + splitTitle: { + fontSize: 72, // 大幅放大主标题 + marginBottom: 48, + textShadowOffset: { width: 0, height: 4 }, + textShadowRadius: 8, + letterSpacing: 4, // 增加字间距,更显大气 + fontWeight: '900', + }, + // 大屏模式副标题样式 - 大幅放大 + splitSubtitle: { + fontSize: 24, // 放大副标题 + marginBottom: 120, // 增加底部间距 + lineHeight: 36, + paddingHorizontal: spacing['4xl'], + letterSpacing: 1.5, + fontWeight: '500', + }, + leftPanelDecorBottom: { + marginTop: spacing['2xl'], + }, + // 大屏模式装饰元素布局 + splitLeftPanelDecorBottom: { + marginTop: 120, + width: '100%', + alignItems: 'center', + }, + // 大屏模式装饰元素水平排列 + splitDecorRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + width: '100%', + gap: spacing['2xl'], // 增加图标间距 + }, + // 大屏模式装饰图标样式 + splitDecorIcon: { + transform: [{ scale: 1.1 }], + opacity: 0.9, + }, + decorRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + width: '80%', + }, + rightPanelContainer: { + flex: 1, // 50% + backgroundColor: colors.background.paper, + }, + rightPanelSafeArea: { + flex: 1, + // 移除左右内边距,交给子容器控制 + }, + // 分栏布局右侧内容包裹器(核心修复) + splitRightContentWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + // 新增:左右内边距,保证表单和边缘有间距 + paddingHorizontal: spacing['2xl'], + }, + // 表单内容包裹器(区分大屏/小屏) + splitFormWrapper: { + // 关键修改:设置最大宽度 + 自适应宽度,实现水平居中 + maxWidth: 480, + width: '100%', + }, + singleFormWrapper: { + flexGrow: 1, + width: '100%', + }, + // 小屏模式:原有默认阴影 + normalFormCardShadow: { + ...shadows.md, + }, + // 大屏模式:专用全向阴影 + splitFormCardShadow: { + // iOS 全向柔和阴影 + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 12, + // Android 高程 + 轻微边框(模拟顶部阴影) + elevation: 8, + // 新增:极细微的顶部边框,强化区分度 + borderTopWidth: 1, + borderTopColor: 'rgba(0,0,0,0.05)', + }, + formTitle: { + fontSize: fontSizes['2xl'], + fontWeight: '700', + color: colors.text.primary, + marginBottom: spacing.xl, + textAlign: 'center', + }, }); export default RegisterScreen; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 4ea3d1a..f576dc1 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -32,6 +32,7 @@ import { PostCard, TabBar, SearchBar } from '../../components/business'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { SearchScreen } from './SearchScreen'; type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; @@ -84,6 +85,9 @@ export const HomeScreen: React.FC = () => { const [postImages, setPostImages] = useState([]); const [selectedImageIndex, setSelectedImageIndex] = useState(0); + // 搜索显示状态(用于内嵌搜索页面) + const [showSearch, setShowSearch] = useState(false); + // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 const postIdsRef = React.useRef>(new Set()); const inFlightRequestKeysRef = React.useRef>(new Set()); @@ -325,9 +329,9 @@ export const HomeScreen: React.FC = () => { }) ), [handleSwipeTabChange]); - // 跳转到搜索页 + // 跳转到搜索页(使用内嵌模式,不再依赖导航) const handleSearchPress = () => { - navigation.navigate('Search'); + setShowSearch(true); }; // 跳转到帖子详情 @@ -630,6 +634,19 @@ export const HomeScreen: React.FC = () => { ); }; + // 搜索页面内嵌模式 + if (showSearch) { + return ( + + + setShowSearch(false)} + /> + + ); + } + + // 正常首页内容 return ( @@ -667,7 +684,20 @@ export const HomeScreen: React.FC = () => { {/* 帖子列表 */} - + {isMobile ? ( + // 移动端:使用 GestureDetector 支持手势切换 Tab + + + {viewMode === 'list' ? ( + renderListContent() + ) : ( + // 网格模式:使用响应式网格 + renderResponsiveGrid() + )} + + + ) : ( + // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击 {viewMode === 'list' ? ( renderListContent() @@ -676,7 +706,7 @@ export const HomeScreen: React.FC = () => { renderResponsiveGrid() )} - + )} {/* 漂浮发帖按钮 */} { {/* 图片查看器 */} ({ - id: img.id || img.url || String(Math.random()), - url: img.url || img.uri || '' + images={postImages.map(img => ({ + id: img.id || img.url || String(Math.random()), + url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={() => setShowImageViewer(false)} @@ -717,6 +747,12 @@ const styles = StyleSheet.create({ }, searchWrapper: { paddingBottom: spacing.sm, + // 移除阴影效果 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, }, viewToggleBtn: { width: 44, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index aa10fba..3c815cc 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -31,8 +31,14 @@ const TABS = ['帖子', '用户']; type SearchType = 'posts' | 'users'; -export const SearchScreen: React.FC = () => { - const navigation = useNavigation(); +interface SearchScreenProps { + onBack?: () => void; + navigation?: NavigationProp; +} + +export const SearchScreen: React.FC = ({ onBack, navigation: propNavigation }) => { + // 如果传入了 navigation 则使用传入的,否则使用 hook 获取的 + const navigation = propNavigation || useNavigation(); const insets = useSafeAreaInsets(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); @@ -422,9 +428,9 @@ export const SearchScreen: React.FC = () => { autoFocus /> - navigation.goBack()} + onBack ? onBack() : navigation.goBack()} activeOpacity={0.85} > { - const navigation = useNavigation(); + const navigation = useNavigation(); const insets = useSafeAreaInsets(); // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); const styles = baseStyles; + + // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页 + useEffect(() => { + if (isWideScreen) { + // 导航到大屏幕模式的主界面首页 + navigation.reset({ + index: 0, + routes: [ + { + name: 'Main', + params: { + screen: 'HomeTab', + params: { screen: 'Home' } + } + } + ] + }); + } + }, [isWideScreen, navigation]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); @@ -93,6 +113,19 @@ export const ChatScreen: React.FC = () => { setShowImageViewer(false); }; + // 群信息面板状态 + const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false); + + // 打开群信息面板 + const handleGroupInfoPress = useCallback(() => { + setShowGroupInfoPanel(true); + }, []); + + // 关闭群信息面板 + const handleCloseGroupInfoPanel = useCallback(() => { + setShowGroupInfoPanel(false); + }, []); + const { // 状态 messages, @@ -125,6 +158,7 @@ export const ChatScreen: React.FC = () => { messageMap, loadingMore, hasMoreHistory, + conversationId, // Refs flatListRef, @@ -222,6 +256,8 @@ export const ChatScreen: React.FC = () => { onBack={() => navigation.goBack()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} + onGroupInfoPress={handleGroupInfoPress} + isWideScreen={isWideScreen} /> {/* 消息列表 */} @@ -369,6 +405,33 @@ export const ChatScreen: React.FC = () => { onClose={handleCloseImageViewer} enableSave /> + + {/* 群信息侧边栏面板 - 仅大屏幕显示 */} + { + // TODO: 实现置顶功能 + console.log('Toggle pin:', pinned); + }} + onLeaveGroup={() => { + // TODO: 实现退出群聊功能 + console.log('Leave group'); + }} + onInviteMembers={() => { + // TODO: 实现邀请新成员功能 + console.log('Invite members'); + }} + onViewAllMembers={() => { + // TODO: 实现查看全部成员功能 + console.log('View all members'); + }} + /> ); }; diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index c3c0ba4..2f35d03 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -215,6 +215,8 @@ const CreateGroupScreen: React.FC = () => { placeholder="请输入群名称" placeholderTextColor={colors.text.hint} maxLength={50} + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> {groupName.length}/50 @@ -238,6 +240,8 @@ const CreateGroupScreen: React.FC = () => { multiline numberOfLines={4} textAlignVertical="top" + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> {groupDescription.length}/500 diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 209a85f..378b1f1 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -110,6 +110,8 @@ const JoinGroupScreen: React.FC = () => { style={styles.input} editable={!searching && !joining} autoCapitalize="none" + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> ; type MessageNavProp = NativeStackNavigationProp; @@ -140,7 +142,15 @@ export const MessageListScreen: React.FC = () => { const navigation = useNavigation(); const isFocused = useIsFocused(); const insets = useSafeAreaInsets(); - const tabBarHeight = useBottomTabBarHeight(); + // 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight + const { isMobile } = useResponsive(); + let tabBarHeight = 0; + try { + tabBarHeight = useBottomTabBarHeight(); + } catch (e) { + // 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可 + tabBarHeight = 0; + } const currentUserId = useAuthStore(state => state.currentUser?.id); const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount); @@ -173,6 +183,30 @@ export const MessageListScreen: React.FC = () => { const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat'); const [actionMenuVisible, setActionMenuVisible] = useState(false); + // 系统通知显示状态 - 用于在移动端显示通知页面 + const [showNotifications, setShowNotifications] = useState(false); + + // 系统消息会话对象(用于选中状态) + const systemMessageConversation: ConversationResponse = useMemo(() => ({ + id: SYSTEM_MESSAGE_CHANNEL_ID, + type: 'private', + last_seq: 0, + last_message: { + id: '0', + conversation_id: SYSTEM_MESSAGE_CHANNEL_ID, + sender_id: 'system', + seq: 0, + segments: [{ type: 'text', data: { text: '系统通知' } }], + status: 'normal', + created_at: new Date().toISOString(), + }, + last_message_at: new Date().toISOString(), + unread_count: systemUnreadCount, + participants: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }), [systemUnreadCount]); + // 动画值 const [scaleAnims] = useState(() => Array.from({ length: 20 }, () => new Animated.Value(1)) @@ -272,19 +306,19 @@ export const MessageListScreen: React.FC = () => { }), ]).start(() => { if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) { - // 跳转到系统通知页面 - 使用正确的导航路径 + // 系统通知 - 根据屏幕宽度决定如何显示 if (isWideScreen) { - // 宽屏下直接导航到通知页面 - navigation.navigate('Notifications' as never); + // 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态 + setSelectedConversation(systemMessageConversation); + setShowNotifications(true); } else { - // 窄屏下使用 Tab 导航 - (navigation as any).navigate('Main', { - screen: 'MessageTab', - params: { screen: 'Notifications' } - }); + // 窄屏下也使用内部状态显示通知页面 + setShowNotifications(true); } } else if (isWideScreen) { // 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天 + // 同时关闭系统通知页面 + setShowNotifications(false); setSelectedConversation(conversation); } else if (conversation.type === 'group' && conversation.group) { // 群聊 - 窄屏下导航 @@ -765,6 +799,9 @@ export const MessageListScreen: React.FC = () => { onChangeText={setSearchText} autoFocus returnKeyType="search" + // 确保光标可见 + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> {searchText.length > 0 && ( setSearchText('')}> @@ -903,7 +940,10 @@ export const MessageListScreen: React.FC = () => { {/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */} - {selectedConversation ? ( + {showNotifications ? ( + // 显示系统通知页面 + setShowNotifications(false)} /> + ) : selectedConversation ? ( // 显示选中会话的聊天内容 { return ( - {isSearchMode ? ( + {showNotifications ? ( + // 显示系统通知页面,传入 onBack 回调 + setShowNotifications(false)} /> + ) : isSearchMode ? ( renderSearchMode() + ) : isWideScreen ? ( + + {renderConversationList()} + ) : ( - isWideScreen ? ( - - {renderConversationList()} - - ) : ( - renderConversationList() - ) + renderConversationList() )} {renderActionMenu()} @@ -1101,7 +1142,7 @@ const styles = StyleSheet.create({ ...shadows.sm, }, conversationItemSelected: { - backgroundColor: '#E3F2FD', + backgroundColor: colors.primary.light + '20', borderColor: colors.primary.main, borderWidth: 1, }, diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index 56cb6e9..6a05791 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -13,18 +13,19 @@ import { TouchableOpacity, RefreshControl, ActivityIndicator, + Dimensions, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useIsFocused } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; import { SystemMessageItem } from '../../components/business'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; -import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { RootStackParamList } from '../../navigation/types'; import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; @@ -41,15 +42,33 @@ const MESSAGE_TYPES = [ const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']); const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']); -export const NotificationsScreen: React.FC = () => { +export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { const isFocused = useIsFocused(); const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const navigation = useNavigation>(); - // 响应式布局 - const { isDesktop, isTablet } = useResponsive(); - const isWideScreen = useBreakpointGTE('lg'); + // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题 + const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + // 延迟设置窗口尺寸,确保在客户端完全加载后再获取 + const timer = setTimeout(() => { + const { width, height } = Dimensions.get('window'); + setWindowSize({ width, height }); + setIsHydrated(true); + }, 100); + return () => clearTimeout(timer); + }, []); + + // 使用本地尺寸状态计算断点 (lg = 768) + const isWideScreen = windowSize.width >= 768; + const isDesktop = windowSize.width >= 1024; + const isTablet = windowSize.width >= 768 && windowSize.width < 1024; + + // Web端使用更大的容器宽度 + const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900; const [messages, setMessages] = useState([]); const [activeType, setActiveType] = useState('all'); @@ -148,16 +167,12 @@ export const NotificationsScreen: React.FC = () => { } }, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]); - // 屏幕失去焦点时返回消息列表 + // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { - if (!isFocused) { - // 使用 setTimeout 确保在导航状态稳定后再执行 - const timer = setTimeout(() => { - navigation.goBack(); - }, 0); - return () => clearTimeout(timer); + if (!isFocused && onBack) { + onBack(); } - }, [isFocused, navigation]); + }, [isFocused, onBack]); // 筛选消息 const filteredMessages = activeType === 'all' @@ -317,21 +332,62 @@ export const NotificationsScreen: React.FC = () => { ); }; + // 渲染头部(包含返回按钮) + const renderHeader = () => { + // 大屏模式(>= lg 断点)隐藏返回按钮 + const shouldShowBackButton = onBack && !isWideScreen; + + return ( + + {shouldShowBackButton ? ( + + + + ) : ( + + )} + + 系统通知 + + + + ); + }; + // 渲染空状态 const renderEmpty = () => ( - + + + ); + // 如果还未hydrated,显示加载占位符,避免竞态条件导致的渲染问题 + if (!isHydrated) { + return ( + + + + + + ); + } + return ( {isWideScreen ? ( - + + {/* 头部 - 大屏模式下隐藏返回按钮 */} + {renderHeader()} {/* 分类筛选 */} - + {MESSAGE_TYPES.map(type => { const count = type.key === 'all' ? displayMessages.length @@ -403,6 +459,8 @@ export const NotificationsScreen: React.FC = () => { ) : ( <> + {/* 头部 - 小屏模式下显示返回按钮 */} + {renderHeader()} {/* 分类筛选 */} {MESSAGE_TYPES.map(type => { @@ -491,10 +549,11 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: colors.divider, }, - filterContainerWide: { + filterContainerWideWeb: { paddingHorizontal: spacing.xl, paddingVertical: spacing.md, justifyContent: 'center', + backgroundColor: colors.background.paper, }, filterTag: { flexDirection: 'row', @@ -516,6 +575,38 @@ const styles = StyleSheet.create({ filterCount: { marginLeft: spacing.xs, }, + // Header 样式 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + headerWide: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.lg, + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'flex-start', + }, + backButtonPlaceholder: { + width: 40, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text.primary, + }, + headerTitleWide: { + fontSize: 20, + }, listContent: { flexGrow: 1, }, @@ -537,4 +628,14 @@ const styles = StyleSheet.create({ loadingMoreText: { marginLeft: spacing.sm, }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyContainerWeb: { + minHeight: 500, + justifyContent: 'center', + paddingTop: 100, + }, }); diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 73f0aeb..437a6d1 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -22,10 +22,15 @@ export const ChatHeader: React.FC = ({ onBack, onTitlePress, onMorePress, + onGroupInfoPress, + isWideScreen: propIsWideScreen, }) => { // 响应式布局 const { width } = useResponsive(); - const isWideScreen = useBreakpointGTE('lg'); + // 使用 768px 作为大屏幕和小屏幕的分界线 + const isWideScreenHook = useBreakpointGTE('lg'); + // 优先使用 props 传入的 isWideScreen,否则使用 hook + const isWideScreen = propIsWideScreen !== undefined ? propIsWideScreen : isWideScreenHook; // 合并样式 const styles = useMemo(() => { @@ -67,12 +72,17 @@ export const ChatHeader: React.FC = ({ return ( - - - + {/* 大屏幕(>= 768px)时隐藏返回按钮 */} + {!isWideScreen ? ( + + + + ) : ( + + )} = ({ - - - + {/* 大屏幕 + 群聊时显示群信息按钮,否则显示三点菜单 */} + {isWideScreen && isGroupChat && onGroupInfoPress ? ( + + + + ) : ( + + + + )} ); diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 8676b1c..79858c8 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -134,6 +134,9 @@ export const ChatInput: React.FC diff --git a/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx new file mode 100644 index 0000000..c89ff3b --- /dev/null +++ b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx @@ -0,0 +1,593 @@ +/** + * GroupInfoPanel.tsx + * 群信息侧边栏组件 + * 大屏幕模式下从右侧滑入显示 + * 实现手机端群信息界面的核心功能 + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + ScrollView, + Dimensions, + Animated, + Alert, +} from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Avatar, Text } from '../../../../components/common'; +import { colors, spacing, fontSizes, shadows } from '../../../../theme'; +import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto'; +import { useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { groupManager } from '../../../../stores/groupManager'; +import { groupService } from '../../../../services/groupService'; + +const { width: screenWidth } = Dimensions.get('window'); +const PANEL_WIDTH = 360; + +interface GroupInfoPanelProps { + visible: boolean; + groupId?: string; + groupInfo: GroupResponse | null; + conversationId?: string; + isPinned?: boolean; + currentUserId?: string; + onClose: () => void; + onTogglePin?: (pinned: boolean) => void; + onLeaveGroup?: () => void; + onInviteMembers?: () => void; + onViewAllMembers?: () => void; +} + +export const GroupInfoPanel: React.FC = ({ + visible, + groupId, + groupInfo, + conversationId, + isPinned, + currentUserId, + onClose, + onTogglePin, + onLeaveGroup, + onInviteMembers, + onViewAllMembers, +}) => { + const isWideScreen = useBreakpointGTE('lg'); + const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current; + const fadeAnim = React.useRef(new Animated.Value(0)).current; + const [members, setMembers] = useState([]); + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(false); + const [showAllMembers, setShowAllMembers] = useState(false); + const [allMembers, setAllMembers] = useState([]); + const [loadingMoreMembers, setLoadingMoreMembers] = useState(false); + + // 加载群成员(初始只加载10个) + const loadMembers = useCallback(async () => { + if (!groupId) return; + try { + setLoading(true); + const response = await groupManager.getMembers(groupId, 1, 10); + setMembers(response.list || []); + } catch (error) { + console.error('[GroupInfoPanel] 加载成员失败:', error); + } finally { + setLoading(false); + } + }, [groupId]); + + // 加载全部群成员 + const loadAllMembers = useCallback(async () => { + if (!groupId) return; + try { + setLoadingMoreMembers(true); + const response = await groupManager.getMembers(groupId, 1, 100); + setAllMembers(response.list || []); + setShowAllMembers(true); + } catch (error) { + console.error('[GroupInfoPanel] 加载全部成员失败:', error); + } finally { + setLoadingMoreMembers(false); + } + }, [groupId]); + + // 加载群公告 + const loadAnnouncements = useCallback(async () => { + if (!groupId) return; + try { + const response = await groupService.getAnnouncements(groupId, 1, 10); + setAnnouncements(response.list || []); + } catch (error) { + console.error('[GroupInfoPanel] 加载公告失败:', error); + } + }, [groupId]); + + // 每次打开面板时重置状态 + useEffect(() => { + if (visible) { + setShowAllMembers(false); + setAllMembers([]); + } + }, [visible]); + + useEffect(() => { + if (visible && groupId) { + loadMembers(); + loadAnnouncements(); + } + }, [visible, groupId, loadMembers, loadAnnouncements]); + + useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: PANEL_WIDTH, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, slideAnim, fadeAnim]); + + if (!isWideScreen) { + return null; + } + + // 获取加群方式文本 + const getJoinTypeText = (joinType?: number): string => { + switch (joinType) { + case 0: + return '允许任何人加入'; + case 1: + return '需要管理员审批'; + case 2: + return '不允许任何人加入'; + default: + return '未知'; + } + }; + + return ( + <> + {/* 遮罩层 */} + {visible && ( + + + + )} + + {/* 侧边栏面板 */} + + {/* 头部 */} + + 群信息 + + + + + + + {/* 群头像和名称 */} + + + {groupInfo?.name || '群聊'} + + {groupInfo?.member_count || members.length || 0} 位成员 + + {groupInfo?.join_type !== undefined && ( + + {getJoinTypeText(groupInfo.join_type)} + + )} + {/* 邀请新成员按钮 */} + {onInviteMembers && ( + + + 邀请新成员 + + )} + + + {/* 分割线 */} + + + {/* 群公告 */} + {announcements.length > 0 && ( + + + + 群公告 + + + {announcements[0].content} + + {new Date(announcements[0].created_at).toLocaleDateString()} + + + + )} + + {/* 群简介 */} + {groupInfo?.description && ( + + 群简介 + + {groupInfo.description} + + + )} + + {/* 群成员列表 */} + + + 成员列表 ({groupInfo?.member_count || members.length}) + {onViewAllMembers && !showAllMembers && members.length >= 10 && ( + + + {loadingMoreMembers ? '加载中...' : '查看全部'} + + + + )} + {onViewAllMembers && showAllMembers && ( + setShowAllMembers(false)} style={styles.viewAllButton}> + 收起 + + + )} + + + {(showAllMembers ? allMembers : members).map((member) => ( + + + + + {member.nickname || member.user?.nickname || '用户'} + + {member.role === 'owner' && ( + 群主 + )} + {member.role === 'admin' && ( + 管理员 + )} + + + ))} + + + + {/* 群信息 */} + + 群信息 + + 群号 + {groupInfo?.id || '-'} + + + 创建时间 + + {groupInfo?.created_at + ? new Date(groupInfo.created_at).toLocaleDateString('zh-CN') + : '-'} + + + + 全员禁言 + + {groupInfo?.mute_all ? '已开启' : '已关闭'} + + + + + {/* 操作按钮 */} + + {conversationId && onTogglePin && ( + onTogglePin(!isPinned)} + activeOpacity={0.7} + > + + + {isPinned ? '取消置顶' : '置顶群聊'} + + + )} + + {onLeaveGroup && ( + + + + 退出群聊 + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + zIndex: 100, + }, + overlayTouchable: { + flex: 1, + }, + panel: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: PANEL_WIDTH, + backgroundColor: '#FFF', + zIndex: 101, + ...shadows.lg, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: '#E8E8E8', + }, + headerTitle: { + fontSize: fontSizes.xl, + fontWeight: '600', + color: '#333', + }, + closeButton: { + padding: spacing.xs, + }, + content: { + flex: 1, + }, + groupInfoSection: { + alignItems: 'center', + paddingVertical: spacing.xl, + }, + groupName: { + fontSize: fontSizes.xl, + fontWeight: '600', + color: '#333', + marginTop: spacing.md, + }, + memberCount: { + fontSize: fontSizes.md, + color: '#999', + marginTop: spacing.xs, + }, + joinType: { + fontSize: fontSizes.sm, + color: colors.primary.main, + marginTop: spacing.xs, + }, + inviteButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + backgroundColor: colors.primary.light + '15', + borderRadius: 20, + marginTop: spacing.md, + borderWidth: 1, + borderColor: colors.primary.light, + }, + inviteButtonText: { + fontSize: fontSizes.sm, + color: colors.primary.main, + marginLeft: spacing.xs, + fontWeight: '500', + }, + divider: { + height: 1, + backgroundColor: '#E8E8E8', + marginHorizontal: spacing.md, + }, + section: { + padding: spacing.md, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + sectionTitle: { + fontSize: fontSizes.md, + fontWeight: '600', + color: '#333', + marginLeft: spacing.xs, + }, + noticeBox: { + backgroundColor: '#FFF9E6', + padding: spacing.md, + borderRadius: 8, + borderLeftWidth: 3, + borderLeftColor: colors.warning.main, + }, + noticeText: { + fontSize: fontSizes.sm, + color: '#666', + lineHeight: 20, + }, + noticeTime: { + fontSize: fontSizes.xs, + color: '#999', + marginTop: spacing.xs, + textAlign: 'right', + }, + descriptionBox: { + backgroundColor: '#F5F7FA', + padding: spacing.md, + borderRadius: 8, + }, + description: { + fontSize: fontSizes.sm, + color: '#666', + lineHeight: 20, + }, + memberList: { + gap: spacing.sm, + }, + memberListHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, + viewAllButton: { + flexDirection: 'row', + alignItems: 'center', + }, + viewAllText: { + fontSize: fontSizes.sm, + color: colors.primary.main, + }, + memberItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs, + }, + memberInfo: { + flex: 1, + marginLeft: spacing.sm, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + memberName: { + fontSize: fontSizes.md, + color: '#333', + flex: 1, + }, + ownerBadge: { + fontSize: fontSizes.xs, + color: colors.warning.main, + marginLeft: spacing.xs, + backgroundColor: colors.warning.light + '30', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + adminBadge: { + fontSize: fontSizes.xs, + color: colors.primary.main, + marginLeft: spacing.xs, + backgroundColor: colors.primary.light + '30', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + moreMembers: { + fontSize: fontSizes.sm, + color: '#999', + textAlign: 'center', + paddingVertical: spacing.sm, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: '#F0F0F0', + }, + infoLabel: { + fontSize: fontSizes.sm, + color: '#999', + }, + infoValue: { + fontSize: fontSizes.sm, + color: '#333', + }, + actionSection: { + padding: spacing.md, + paddingBottom: spacing.xl, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + backgroundColor: colors.primary.light + '15', + borderRadius: 12, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: colors.primary.light, + }, + actionButtonText: { + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.primary.main, + marginLeft: spacing.sm, + }, + leaveButton: { + backgroundColor: colors.error.light + '15', + borderColor: colors.error.light, + }, + leaveButtonText: { + color: colors.error.main, + }, +}); + +export default GroupInfoPanel; diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index f2623fd..a7d92e0 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -130,9 +130,22 @@ const ImageSegment: React.FC<{ }> = ({ data, isMe, onImagePress, onImageLongPress }) => { const pressPositionRef = useRef({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); + const [loadError, setLoadError] = useState(false); const imageUrl = data.thumbnail_url || data.url; + // 如果没有有效的图片URL,显示图片占位符 + const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; + useEffect(() => { + // 重置状态 + setLoadError(false); + + // 如果没有有效的图片URL,设置默认尺寸用于显示占位符 + if (!hasValidUrl) { + setDimensions(IMAGE_FALLBACK_SIZE); + return; + } + // 如果已经有宽高信息,直接使用 if (data.width && data.height) { const aspectRatio = data.width / data.height; @@ -191,7 +204,7 @@ const ImageSegment: React.FC<{ ); return () => clearTimeout(timeoutId); - }, [imageUrl, data.width, data.height]); + }, [imageUrl, data.width, data.height, hasValidUrl]); // 记录按压位置 const handlePressIn = (event: GestureResponderEvent) => { @@ -204,6 +217,35 @@ const ImageSegment: React.FC<{ onImageLongPress?.(pressPositionRef.current); }; + // 没有有效URL或加载失败时显示占位符 + if (!hasValidUrl || loadError) { + return ( + hasValidUrl && onImagePress?.(data.url)} + onLongPress={handleLongPress} + delayLongPress={500} + activeOpacity={0.9} + > + + + 图片 + + + ); + } + // 初始加载时显示占位 if (!dimensions) { return ( @@ -248,6 +290,9 @@ const ImageSegment: React.FC<{ contentFit="cover" cachePolicy="disk" priority="normal" + onError={() => { + setLoadError(true); + }} /> ); diff --git a/src/screens/message/components/ChatScreen/index.ts b/src/screens/message/components/ChatScreen/index.ts index 44cd908..c905b0f 100644 --- a/src/screens/message/components/ChatScreen/index.ts +++ b/src/screens/message/components/ChatScreen/index.ts @@ -19,6 +19,7 @@ export { LongPressMenu } from './LongPressMenu'; export { ChatHeader } from './ChatHeader'; export { MessageBubble } from './MessageBubble'; export { ChatInput } from './ChatInput'; +export { GroupInfoPanel } from './GroupInfoPanel'; // Segment 渲染组件 export { diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 3eaf6cb..1a2bd32 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -322,13 +322,18 @@ export const chatScreenStyles = StyleSheet.create({ inputContainer: { backgroundColor: colors.background.paper, borderWidth: 1, - borderColor: `${colors.divider}88`, + borderColor: colors.divider, borderRadius: 24, marginHorizontal: 14, marginBottom: 12, paddingHorizontal: spacing.sm, paddingVertical: spacing.sm, - ...shadows.lg, + // 移除明显的阴影效果,改用更微妙的边框 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, }, inputInner: { flexDirection: 'row', @@ -338,6 +343,12 @@ export const chatScreenStyles = StyleSheet.create({ paddingHorizontal: spacing.xs, paddingVertical: 4, minHeight: 44, + // 移除阴影效果 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, }, inputInnerMuted: { backgroundColor: '#F0F0F0', @@ -395,6 +406,12 @@ export const chatScreenStyles = StyleSheet.create({ paddingHorizontal: spacing.xs, maxHeight: 100, justifyContent: 'center', + // 移除阴影 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, }, input: { fontSize: 16, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 794651d..ea704fa 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -163,6 +163,10 @@ export interface ChatHeaderProps { onBack: () => void; onTitlePress: () => void; onMorePress: () => void; + /** 大屏幕下显示群信息面板的回调 */ + onGroupInfoPress?: () => void; + /** 是否为大屏幕模式 */ + isWideScreen?: boolean; } // 回复预览 Props diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 9e610b4..8f7eecf 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -14,26 +14,40 @@ import { Platform, TextInput, Dimensions, + Animated, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Image as ExpoImage } from 'expo-image'; import { colors, spacing, fontSizes, shadows } from '../../../theme'; -import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto'; -import { Avatar, Text } from '../../../components/common'; +import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; +import { Avatar, Text, ImageGallery } from '../../../components/common'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { extractTextFromSegments } from '../../../types/dto'; +import { useBreakpointGTE } from '../../../hooks/useResponsive'; +import { useMarkAsRead } from '../../../stores/messageManagerHooks'; +import { messageService } from '../../../services'; +import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; +import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; + +const { width: screenWidth } = Dimensions.get('window'); +const PANEL_WIDTH = 360; interface EmbeddedChatProps { conversation: ConversationResponse; onBack: () => void; } -const { width: screenWidth } = Dimensions.get('window'); - export const EmbeddedChat: React.FC = ({ conversation, onBack }) => { const navigation = useNavigation(); const currentUser = useAuthStore(state => state.currentUser); + // 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线 + const isWideScreen = useBreakpointGTE('lg'); + + // 使用 markAsRead hook + const { markAsRead } = useMarkAsRead(String(conversation.id)); + // 状态 const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -42,6 +56,61 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack const flatListRef = useRef(null); const inputRef = useRef(null); + // 图片预览状态 + const [imageGalleryVisible, setImageGalleryVisible] = useState(false); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [galleryImages, setGalleryImages] = useState<{ url: string }[]>([]); + + // 右键菜单状态 + const [contextMenuVisible, setContextMenuVisible] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState(); + const [selectedMessageForMenu, setSelectedMessageForMenu] = useState(null); + + // 回复状态 + const [replyingToMessage, setReplyingToMessage] = useState(null); + + // 群信息面板动画 + const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; + const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false); + + // 群成员列表 + const [groupMembers, setGroupMembers] = useState([]); + + // 打开/关闭群信息面板 + const toggleGroupInfoPanel = useCallback(() => { + if (showGroupInfoPanel) { + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: PANEL_WIDTH, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(() => { + setShowGroupInfoPanel(false); + }); + } else { + setShowGroupInfoPanel(true); + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } + }, [showGroupInfoPanel, slideAnim, fadeAnim]); + // 获取会话信息 const isGroupChat = conversation.type === 'group'; const chatTitle = isGroupChat @@ -88,6 +157,18 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack }; }, [conversation.id, loadMessages]); + // 自动标记已读 - 当有新消息时 + useEffect(() => { + if (messages.length === 0) return; + + const maxSeq = Math.max(...messages.map(m => m.seq), 0); + if (maxSeq > 0) { + markAsRead(maxSeq).catch(error => { + console.error('[EmbeddedChat] 自动标记已读失败:', error); + }); + } + }, [messages, markAsRead]); + // 发送消息 const handleSend = useCallback(async () => { if (!inputText.trim() || sending) return; @@ -127,32 +208,193 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack } }; + // 处理图片点击预览 + const handleImagePress = (images: { url: string }[], index: number) => { + setGalleryImages(images); + setCurrentImageIndex(index); + setImageGalleryVisible(true); + }; + + // 处理右键点击消息 + const handleMessageRightPress = (message: MessageResponse, event: any) => { + // 获取鼠标右键位置 + const pageX = event?.nativeEvent?.pageX || 0; + const pageY = event?.nativeEvent?.pageY || 0; + + setSelectedMessageForMenu(message as GroupMessage); + setContextMenuPosition({ + x: pageX, + y: pageY, + width: 0, + height: 0, + pressX: pageX, + pressY: pageY, + }); + setContextMenuVisible(true); + }; + + // 关闭右键菜单 + const handleContextMenuClose = () => { + setContextMenuVisible(false); + setSelectedMessageForMenu(null); + setContextMenuPosition(undefined); + }; + + // 处理回复消息 + const handleReply = (message: GroupMessage) => { + setReplyingToMessage(message); + handleContextMenuClose(); + }; + + // 处理撤回消息 + const handleRecall = async (messageId: string) => { + try { + await messageService.recallMessage(messageId); + handleContextMenuClose(); + } catch (error) { + console.error('[EmbeddedChat] 撤回消息失败:', error); + } + }; + + // 处理删除消息 + const handleDeleteMessage = async (messageId: string) => { + try { + await messageService.deleteMessage(messageId); + handleContextMenuClose(); + } catch (error) { + console.error('[EmbeddedChat] 删除消息失败:', error); + } + }; + + // 渲染消息内容 + const renderMessageContent = (item: MessageResponse) => { + // 撤回消息 + if (item.status === 'recalled') { + return ( + + 消息已撤回 + + ); + } + + // 获取消息 segments + const segments = item.segments || []; + + // 检查是否有图片消息 + const imageSegments = segments + .filter(s => s.type === 'image') + .map(s => s.data as ImageSegmentData); + + // 提取文本内容 + const textContent = extractTextFromSegments(segments); + + // 渲染多个内容(文本 + 图片) + return ( + + {/* 文本内容 */} + {textContent && textContent !== '[图片]' && ( + + {textContent} + + )} + + {/* 图片内容 */} + {imageSegments.length > 0 && ( + + {imageSegments.map((imgData, idx) => { + const imageUrl = imgData.thumbnail_url || imgData.url; + const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; + + if (!hasValidUrl) { + // 无效URL显示占位符 + return ( + + + 图片 + + ); + } + + // 计算图片尺寸 + let width = 180; + let height = 180; + if (imgData.width && imgData.height) { + const aspectRatio = imgData.width / imgData.height; + if (aspectRatio > 1) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } + } + + return ( + handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)} + activeOpacity={0.8} + > + + + ); + })} + + )} + + {/* 如果没有文本也没有图片,显示空消息提示 */} + {!textContent && imageSegments.length === 0 && ( + + [消息格式错误] + + )} + + ); + }; + // 渲染消息 const renderMessage = ({ item }: { item: MessageResponse }) => { const isMe = String(item.sender_id) === String(currentUser?.id); + // 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键 + const handlePointerDown = (e: any) => { + // e.nativeEvent.button: 0=左键, 1=中键, 2=右键 + const isRightClick = e.nativeEvent?.button === 2; + if (isRightClick) { + // 阻止默认右键菜单 + e.preventDefault?.(); + e.stopPropagation?.(); + handleMessageRightPress(item, e); + } + }; + return ( - + {!isMe && ( - )} {isGroupChat && !isMe && ( {item.sender?.nickname || item.sender?.username} )} - - {item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)} - + {renderMessageContent(item)} {isMe && ( - )} @@ -163,9 +405,14 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {/* 头部 */} - - - + {/* 大屏幕(>= 768px)时隐藏返回按钮 */} + {!isWideScreen ? ( + + + + ) : ( + + )} @@ -174,9 +421,16 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack - - - + {/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */} + {isWideScreen && isGroupChat ? ( + + + + ) : ( + + + + )} {/* 消息列表 */} @@ -231,6 +485,9 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack returnKeyType="send" onSubmitEditing={handleSend} blurOnSubmit={false} + // 确保光标可见 + cursorColor={colors.primary.main} + selectionColor={`${colors.primary.main}40`} /> @@ -252,6 +509,96 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack + + {/* 群信息侧边栏面板 - 仅大屏幕显示 */} + {isWideScreen && isGroupChat && ( + <> + {/* 遮罩层 */} + {showGroupInfoPanel && ( + + + + )} + + {/* 侧边栏面板 */} + + { + // TODO: 实现置顶功能 + console.log('Toggle pin:', pinned); + }} + onLeaveGroup={() => { + // TODO: 实现退出群聊功能 + console.log('Leave group'); + }} + onInviteMembers={() => { + // TODO: 实现邀请新成员功能 + console.log('Invite members'); + }} + onViewAllMembers={() => { + // TODO: 实现查看全部成员功能 + console.log('View all members'); + }} + /> + + + )} + + {/* 右键菜单 */} + + + {/* 图片查看器 */} + ({ + id: String(idx), + url: img.url || '' + }))} + initialIndex={currentImageIndex} + onClose={() => setImageGalleryVisible(false)} + enableSave + /> ); }; @@ -328,7 +675,7 @@ const styles = StyleSheet.create({ }, messageRowRight: { alignSelf: 'flex-end', - flexDirection: 'row-reverse', + justifyContent: 'flex-end', }, messageBubble: { maxWidth: screenWidth * 0.5, @@ -387,6 +734,12 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, minHeight: 40, justifyContent: 'center', + // 移除阴影 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, }, textInput: { fontSize: 15, @@ -406,4 +759,33 @@ const styles = StyleSheet.create({ sendButtonDisabled: { backgroundColor: '#E0E0E0', }, + messageTextRecalled: { + fontStyle: 'italic', + color: '#999', + }, + imageGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: 4, + }, + messageImage: { + borderRadius: 8, + marginRight: 4, + marginBottom: 4, + }, + imagePlaceholder: { + width: 120, + height: 120, + borderRadius: 8, + backgroundColor: 'rgba(0,0,0,0.1)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 4, + marginBottom: 4, + }, + imagePlaceholderText: { + color: '#999', + fontSize: 12, + marginTop: 4, + }, }); diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index 4a9f00c..1053576 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -37,7 +37,14 @@ const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; export const ProfileScreen: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const tabBarHeight = useBottomTabBarHeight(); + // 在大屏幕模式下不使用 Bottom Tab Navigator,所以 try-catch 处理 + let tabBarHeight = 0; + try { + tabBarHeight = useBottomTabBarHeight(); + } catch (e) { + // 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可 + tabBarHeight = 0; + } const homeNavigation = useNavigation(); // 使用 any 类型来访问根导航 const rootNavigation = useNavigation(); diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index f73a915..b9e44b0 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -30,6 +30,7 @@ import { TimeSlot, getCourseColor, hasCourseInWeek, + getCurrentWeek, } from '../../types/schedule'; import type { ScheduleStackParamList } from '../../navigation/types'; import { scheduleService } from '../../services/scheduleService'; @@ -130,9 +131,17 @@ const getWeekDates = (weekOffset: number = 0) => { return getWeekInfo(weekOffset).dates; }; +// 学期起始日期字符串(用于 getCurrentWeek 函数) +const SEMESTER_START_DATE = '2026-03-09'; + +// 计算当前实际周数 +const getInitialWeek = (): number => { + return getCurrentWeek(SEMESTER_START_DATE, TOTAL_WEEKS); +}; + export const ScheduleScreen: React.FC = () => { const navigation = useNavigation>(); - const [currentWeek, setCurrentWeek] = useState(1); + const [currentWeek, setCurrentWeek] = useState(getInitialWeek); const [courses, setCourses] = useState([]); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [pendingDay, setPendingDay] = useState(0); @@ -163,8 +172,10 @@ export const ScheduleScreen: React.FC = () => { }; }, []); const todayColumnIndex = getTodayColumnIndex(); - // currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列 - const isViewingCurrentWeek = currentWeek === 1; + // 获取当前实际周数,用于判断是否在查看当前周 + const actualCurrentWeek = getInitialWeek(); + // 只有在查看当前实际周时才高亮今日列 + const isViewingCurrentWeek = currentWeek === actualCurrentWeek; const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1; const dayColumnWidth = getDayColumnWidth(); diff --git a/src/services/scheduleService.ts b/src/services/scheduleService.ts index 875d20c..1b6c551 100644 --- a/src/services/scheduleService.ts +++ b/src/services/scheduleService.ts @@ -45,22 +45,42 @@ interface SyncScheduleResponse { error_message?: string; } -const toCourse = (dto: ScheduleCourseDTO): Course => ({ - id: dto.id, - name: dto.name, - teacher: dto.teacher, - location: dto.location, - dayOfWeek: dto.day_of_week, - startSection: dto.start_section, - endSection: dto.end_section, - weeks: dto.weeks ?? [], - color: dto.color, -}); +const toCourse = (dto: ScheduleCourseDTO): Course => { + // 调试日志:检查后端返回的 weeks 字段 + console.log('[ScheduleService] 转换课程 DTO:', { + id: dto.id, + name: dto.name, + day_of_week: dto.day_of_week, + weeks: dto.weeks, + weeksType: typeof dto.weeks, + weeksLength: dto.weeks?.length, + }); + return { + id: dto.id, + name: dto.name, + teacher: dto.teacher, + location: dto.location, + dayOfWeek: dto.day_of_week, + startSection: dto.start_section, + endSection: dto.end_section, + weeks: dto.weeks ?? [], + color: dto.color, + }; +}; class ScheduleService { async getCourses(week?: number): Promise { const params = week ? { week } : undefined; + console.log('[ScheduleService] 获取课程列表, params:', params); const response = await api.get('/schedule/courses', params); + console.log('[ScheduleService] 课程列表响应, 数量:', response.data.list.length); + // 打印原始响应数据中的 weeks 字段 + response.data.list.forEach((item, index) => { + console.log(`[ScheduleService] 课程[${index}] ${item.name}:`, { + day_of_week: item.day_of_week, + weeks: item.weeks, + }); + }); return response.data.list.map(toCourse); } @@ -79,7 +99,9 @@ class ScheduleService { } async syncSchedule(req: SyncScheduleRequest): Promise { + console.log('[ScheduleService] 开始同步教务系统, username:', req.username); const response = await api.post('/schedule/sync', req); + console.log('[ScheduleService] 同步响应:', response.data); return response.data; } } diff --git a/src/theme/index.ts b/src/theme/index.ts index 77291c8..4a511c4 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -6,9 +6,9 @@ // ==================== 颜色系统 ==================== export const colors = { primary: { - main: '#FF6B35', // 胡萝卜橙 - light: '#FF8F66', - dark: '#E5521D', + main: '#FF6B35', // 橙色(主色) + light: '#FFB733', + dark: '#CC8400', contrast: '#FFFFFF', }, secondary: { @@ -16,6 +16,18 @@ export const colors = { light: '#80E27E', dark: '#087F23', }, + neutral: { + main: '#6B7280', // 中性灰(辅助色) + light: '#9CA3AF', + dark: '#374151', + bg: '#F3F4F6', // 浅灰背景 + bgDark: '#1F2937', // 深灰背景 + }, + accent: { + main: '#E57373', // 低饱和红(点缀色) + light: '#FFCDD2', + dark: '#C62828', + }, background: { default: '#F5F5F5', paper: '#FFFFFF', @@ -73,6 +85,8 @@ export const spacing = { '2xl': 24, '3xl': 32, '4xl': 40, + '5xl': 48, + '6xl': 56, }; // ==================== 圆角系统 ==================== diff --git a/src/utils/inputStyles.ts b/src/utils/inputStyles.ts new file mode 100644 index 0000000..202da48 --- /dev/null +++ b/src/utils/inputStyles.ts @@ -0,0 +1,33 @@ +/** + * 输入框辅助样式 + * 用于处理React Native Web在浏览器中的默认focus outline样式 + */ + +/** + * 移除浏览器默认focus outline的样式 + * 这个样式可以覆盖浏览器原生的黑框/蓝框 + */ +export const noFocusOutline = { + // 移除浏览器默认的outline(针对Web) + outlineWidth: 0, + outline: 'none' as const, + outlineColor: 'transparent', + // 兼容React Native Web + ':focus': { + outlineWidth: 0, + outline: 'none', + }, + // Webkit浏览器兼容 + '::placeholder': { + outlineWidth: 0, + }, +}; + +/** + * 为TextInput添加无outline样式 + */ +export const inputNoFocusOutline = { + ...noFocusOutline, + // 确保placeholder也没有outline + placeholderTextColor: undefined, // 会在使用时覆盖 +};