From a8c78f0c3f5e666da52374fcda3469b9b00fefb2 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 03:22:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5dev=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E7=95=99=E5=BD=93=E5=89=8D=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/business/SystemMessageItem.tsx | 4 +- src/hooks/useCursorPagination.ts | 111 +++++++++- src/infrastructure/pagination/types.ts | 2 + src/navigation/SimpleMobileTabNavigator.tsx | 10 +- src/screens/home/HomeScreen.tsx | 10 +- src/screens/message/MessageListScreen.tsx | 6 +- src/screens/message/NotificationsScreen.tsx | 205 +++++++++++------- src/screens/profile/AccountSecurityScreen.tsx | 12 +- src/screens/profile/BlockedUsersScreen.tsx | 10 +- src/screens/profile/EditProfileScreen.tsx | 5 +- .../profile/NotificationSettingsScreen.tsx | 12 +- src/screens/profile/ProfileScreen.tsx | 6 +- src/screens/profile/SettingsScreen.tsx | 10 +- src/screens/schedule/ScheduleScreen.tsx | 16 +- src/services/messageService.ts | 45 +++- src/services/postService.ts | 125 ++++++++++- 16 files changed, 461 insertions(+), 128 deletions(-) diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 64894ec..4d8109b 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -203,8 +203,8 @@ const SystemMessageItem: React.FC = ({ alignItems: 'flex-start', padding: containerPadding, backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, + // 移除底部边框,使用卡片式设计 + marginBottom: 1, }, iconContainer: { marginRight: spacing.md, diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 403316e..9ba0a56 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { CursorPaginationState, CursorPaginationConfig, @@ -13,7 +13,7 @@ const MAX_PAGE_SIZE = 100; /** * 游标分页 Hook - * + * * @param fetchFunction 数据获取函数 * @param config 分页配置 * @param extraParams 额外参数(会传递给 fetchFunction) @@ -23,7 +23,7 @@ export function useCursorPagination( config: Partial = {}, extraParams?: P ): UseCursorPaginationReturn { - const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config; + const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false, autoLoad = true } = config; // 限制 pageSize 在有效范围内 const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); @@ -32,7 +32,7 @@ export function useCursorPagination( items: [], nextCursor: null, prevCursor: null, - hasMore: false, + hasMore: true, // 初始设为 true,以便首次加载可以执行 isLoading: false, isRefreshing: false, error: null, @@ -42,12 +42,28 @@ export function useCursorPagination( // 用于取消请求的标志 const cancelledRef = useRef(false); + // 用于追踪是否已自动加载 + const autoLoadRef = useRef(false); + + // 用于追踪是否为首次加载的 ref + const isFirstLoadRef = useRef(true); + + // 用于追踪是否正在加载中(防止重复调用) + const isLoadingRef = useRef(false); + // 加载更多(下一页) const loadMore = useCallback(async () => { - if (state.isLoading || !state.hasMore) { + // 使用 ref 来检查是否为首次加载,避免依赖项变化 + const isFirstLoad = isFirstLoadRef.current; + + // 首次加载时允许执行(跳过 hasMore 检查) + // 使用 isLoadingRef 防止重复调用 + if (isLoadingRef.current || state.isLoading || (!isFirstLoad && !state.hasMore)) { return; } + // 标记正在加载 + isLoadingRef.current = true; cancelledRef.current = false; setState(prev => ({ ...prev, isLoading: true, error: null })); @@ -60,7 +76,13 @@ export function useCursorPagination( extraParams, }); - if (cancelledRef.current) return; + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + // 标记首次加载完成 + isFirstLoadRef.current = false; setState(prev => ({ ...prev, @@ -71,14 +93,21 @@ export function useCursorPagination( isLoading: false, isFirstLoad: false, })); + + isLoadingRef.current = false; } catch (error) { - if (cancelledRef.current) return; + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } setState(prev => ({ ...prev, isLoading: false, error: error instanceof Error ? error.message : '加载失败', })); + + isLoadingRef.current = false; } }, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]); @@ -163,11 +192,14 @@ export function useCursorPagination( // 重置状态 const reset = useCallback(() => { cancelledRef.current = true; + autoLoadRef.current = false; + isFirstLoadRef.current = true; + isLoadingRef.current = false; setState({ items: [], nextCursor: null, prevCursor: null, - hasMore: false, + hasMore: true, // 重置后也设为 true,以便可以重新加载 isLoading: false, isRefreshing: false, error: null, @@ -195,6 +227,69 @@ export function useCursorPagination( [] ); + // 自动加载第一页 + useEffect(() => { + // 使用 ref 防止重复加载,不依赖 loadMore 函数引用 + // 只检查 autoLoad 和 autoLoadRef,不再依赖 state.items.length + if (!autoLoad || autoLoadRef.current) return; + + autoLoadRef.current = true; + + // 直接调用加载逻辑,避免依赖 loadMore 函数引用 + const loadInitialData = async () => { + if (isLoadingRef.current) return; + + isLoadingRef.current = true; + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + isFirstLoadRef.current = false; + + setState(prev => ({ + ...prev, + items: response.items, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isFirstLoad: false, + })); + + isLoadingRef.current = false; + } catch (error) { + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + setState(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : '加载失败', + })); + + isLoadingRef.current = false; + } + }; + + loadInitialData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad]); // 只依赖 autoLoad,确保只执行一次 + return { ...state, loadMore, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index 3ea56de..f07466a 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -196,6 +196,8 @@ export interface CursorPaginationConfig { pageSize: number; /** 是否启用双向分页 */ bidirectional?: boolean; + /** 是否自动加载第一页,默认为 true */ + autoLoad?: boolean; } /** diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index c50c951..6d81c2f 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -269,6 +269,9 @@ export function SimpleMobileTabNavigator() { ); }; + // 计算底部安全距离 + const bottomSafeArea = TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2 + insets.bottom; + return ( {/* 内容区域 */} @@ -331,7 +334,9 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2, + }, + bottomSpacer: { + backgroundColor: 'transparent', }, tabBar: { position: 'absolute', @@ -340,8 +345,7 @@ const styles = StyleSheet.create({ height: TAB_BAR_HEIGHT, backgroundColor: colors.background.paper, borderRadius: 24, - borderTopWidth: 1, - borderTopColor: `${colors.divider}88`, + // 移除顶部边框,避免与内容之间出现线条 flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index c889eb0..66f37d9 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -179,8 +179,9 @@ export const HomeScreen: React.FC = () => { if (!isMobile) { return undefined; } - // 往底部导航栏靠近一个按钮的位置 - return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT; + // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方 + // TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16 + return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom; }, [isMobile, insets.bottom]); // 切换视图模式 @@ -375,6 +376,11 @@ export const HomeScreen: React.FC = () => { const columns: Post[][] = Array.from({ length: gridColumns }, () => []); const columnHeights: number[] = Array(gridColumns).fill(0); + // 防御性检查:确保 posts 存在且是数组 + if (!posts || !Array.isArray(posts) || posts.length === 0) { + return columns; + } + // 计算单列宽度 const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 918e8f7..a3a4d29 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -249,10 +249,12 @@ export const MessageListScreen: React.FC = () => { }, [width]); // 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出 + // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 const listBottomInset = useMemo(() => { if (isWideScreen) return spacing.lg; - return tabBarHeight + insets.bottom + spacing.md; - }, [isWideScreen, tabBarHeight, insets.bottom]); + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isWideScreen, insets.bottom]); // 【新架构】页面获得焦点时初始化MessageManager useEffect(() => { diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index bef0104..028c566 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -5,7 +5,7 @@ * 支持响应式布局 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { View, FlatList, @@ -20,7 +20,7 @@ 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 { colors, spacing, borderRadius } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; @@ -75,6 +75,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const [unreadCount, setUnreadCount] = useState(0); // 【游标分页】使用 useCursorPagination hook 获取系统消息列表 + // 使用 useCallback 包裹 fetchFunction,避免无限重新创建导致循环 + const fetchSystemMessages = useCallback( + async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => { + return await messageService.getSystemMessagesCursor({ + cursor, + page_size: pageSize, + }); + }, + [] + ); + const { items: messages, isLoading, @@ -83,15 +94,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack loadMore, refresh, error: paginationError, - } = useCursorPagination( - async ({ cursor, pageSize }) => { - return await messageService.getSystemMessagesCursor({ - cursor, - page_size: pageSize, - }); - }, - { pageSize: 20 } - ); + } = useCursorPagination(fetchSystemMessages, { pageSize: 20 }); // 本地刷新状态(仅用于下拉刷新的UI显示) const [refreshing, setRefreshing] = useState(false); @@ -161,13 +164,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 + // 使用 ref 防止重复执行,避免无限循环 + const hasInitializedRef = useRef(false); useEffect(() => { - if (isFocused) { + if (isFocused && !hasInitializedRef.current) { + hasInitializedRef.current = true; fetchUnreadCount(); // 进入界面自动标记所有消息为已读 handleMarkAllRead(); } - }, [isFocused, fetchUnreadCount, handleMarkAllRead]); + }, [isFocused]); // 只依赖 isFocused,函数使用 ref 稳定引用 // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { @@ -328,8 +334,8 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack return ( {shouldShowBackButton ? ( - @@ -338,9 +344,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ) : ( )} - - 系统通知 - + + + 系统通知 + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + ); @@ -388,30 +401,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -463,29 +480,33 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -529,13 +550,70 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + // Header 样式 - 美化版 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + // 移除底部边框,使用更柔和的分隔方式 + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + headerWide: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.lg, + }, + headerTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text.primary, + }, + headerTitleWide: { + fontSize: 20, + }, + unreadBadge: { + backgroundColor: colors.primary.main, + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + marginLeft: spacing.sm, + minWidth: 20, + alignItems: 'center', + justifyContent: 'center', + }, + unreadBadgeText: { + color: colors.text.inverse, + fontSize: 11, + fontWeight: '600', + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'flex-start', + }, + backButtonPlaceholder: { + width: 40, + }, + // 分类筛选 - 美化版(使用分段式设计) filterContainer: { flexDirection: 'row', - paddingHorizontal: spacing.lg, + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, + // 移除底部边框,让界面更简洁 }, filterContainerWideWeb: { paddingHorizontal: spacing.xl, @@ -548,55 +626,36 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - marginRight: spacing.sm, - borderRadius: 16, + marginRight: spacing.xs, + borderRadius: borderRadius.lg, backgroundColor: colors.background.default, }, filterTagWide: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, - marginRight: spacing.md, + marginRight: spacing.sm, }, filterTagActive: { - backgroundColor: colors.primary.light + '30', + backgroundColor: colors.primary.main, + }, + filterTagTextActive: { + fontWeight: '600', }, filterCount: { marginLeft: spacing.xs, - }, - // Header 样式 - header: { - flexDirection: 'row', + backgroundColor: colors.primary.light + '40', + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 8, + minWidth: 18, 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, + filterCountActive: { + backgroundColor: 'rgba(255, 255, 255, 0.25)', }, listContent: { flexGrow: 1, + paddingTop: spacing.sm, }, listContentWide: { paddingHorizontal: spacing.xl, diff --git a/src/screens/profile/AccountSecurityScreen.tsx b/src/screens/profile/AccountSecurityScreen.tsx index e65bcb7..1fb1e2f 100644 --- a/src/screens/profile/AccountSecurityScreen.tsx +++ b/src/screens/profile/AccountSecurityScreen.tsx @@ -7,7 +7,7 @@ import { ActivityIndicator, ScrollView, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Text, ResponsiveContainer } from '../../components/common'; @@ -17,9 +17,13 @@ import { showPrompt } from '../../services/promptService'; import { useAuthStore } from '../../stores'; export const AccountSecurityScreen: React.FC = () => { - const { isWideScreen } = useResponsive(); + const { isWideScreen, isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const currentUser = useAuthStore((state) => state.currentUser); const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const [email, setEmail] = useState(currentUser?.email || ''); const [verificationCode, setVerificationCode] = useState(''); @@ -330,10 +334,10 @@ export const AccountSecurityScreen: React.FC = () => { {isWideScreen ? ( - {content} + {content} ) : ( - {content} + {content} )} ); diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index 9ba463b..c91117b 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -8,7 +8,7 @@ import { View, Alert, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; @@ -16,15 +16,21 @@ import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { RootStackParamList } from '../../navigation/types'; +import { useResponsive } from '../../hooks'; type NavigationProp = NativeStackNavigationProp; export const BlockedUsersScreen: React.FC = () => { const navigation = useNavigation(); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [processingUserId, setProcessingUserId] = useState(null); + + // 底部间距,避免被 TabBar 遮挡 + const listBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const loadBlockedUsers = useCallback(async () => { try { @@ -115,7 +121,7 @@ export const BlockedUsersScreen: React.FC = () => { data={users} keyExtractor={item => item.id} renderItem={renderItem} - contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]} + contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent, { paddingBottom: listBottomInset }]} refreshControl={ { const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); const [saving, setSaving] = useState(false); - const bottomSafeDistance = isMobile ? insets.bottom + 92 : spacing.xl; + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg; // 根据屏幕宽度计算封面高度 const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16; @@ -471,7 +472,7 @@ export const EditProfileScreen: React.FC = () => { {/* 保存按钮 */} - + { const [vibrationEnabled, setVibrationEnabledState] = useState(true); const [pushEnabled, setPushEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true); - const { isWideScreen } = useResponsive(); + const { isWideScreen, isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; // 加载设置 useEffect(() => { @@ -150,12 +154,12 @@ export const NotificationSettingsScreen: React.FC = () => { {isWideScreen ? ( - + {renderContent()} ) : ( - + {renderContent()} )} diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index c460d9b..165ebac 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -55,10 +55,12 @@ export const ProfileScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); // 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡 + // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 const scrollBottomInset = useMemo(() => { if (isDesktop) return spacing.lg; - return tabBarHeight + insets.bottom + spacing.md; - }, [isDesktop, tabBarHeight, insets.bottom]); + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isDesktop, insets.bottom]); const [activeTab, setActiveTab] = useState(0); const [posts, setPosts] = useState([]); diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index e61abc4..9f5c434 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -20,6 +20,7 @@ import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; import { Text, ResponsiveContainer } from '../../components/common'; import { useResponsive } from '../../hooks'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; interface SettingsItem { key: string; @@ -67,6 +68,11 @@ export const SettingsScreen: React.FC = () => { const navigation = useNavigation(); const { logout } = useAuthStore(); const { isWideScreen } = useResponsive(); + const insets = useSafeAreaInsets(); + const { isMobile } = useResponsive(); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; // 处理设置项点击 const handleItemPress = (key: string) => { @@ -200,12 +206,12 @@ export const SettingsScreen: React.FC = () => { {isWideScreen ? ( - + {renderContent()} ) : ( - + {renderContent()} )} diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index 6db217c..327110f 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -19,7 +19,7 @@ import { } from 'react-native'; import { PanGestureHandler, State } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -50,8 +50,8 @@ const DEFAULT_SECTION_HEIGHT = 105; const WEEK_SELECTOR_HEIGHT = 44; // 星期标题行高度 const HEADER_HEIGHT = 40; -// 底部Tab栏高度(用于避让) -const TAB_BAR_HEIGHT = 80; +// 悬浮TabBar高度(TabBar高度64 + 浮动间距12*2 = 88) +const FLOATING_TAB_BAR_HEIGHT = 88; // 大节时间配置(左侧显示 1-6) const GROUPED_TIME_SLOTS: TimeSlot[] = [ @@ -150,6 +150,7 @@ export const ScheduleScreen: React.FC = () => { const navigation = useNavigation>(); // 使用响应式 hook 检测屏幕尺寸 const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive(); + const insets = useSafeAreaInsets(); const isWeb = platform.isWeb; const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK); const [courses, setCourses] = useState([]); @@ -166,18 +167,19 @@ export const ScheduleScreen: React.FC = () => { }, [screenWidth, isWeb]); // 动态计算每节课的高度 - // 手机端:第六节课底部刚好跟底部导航栏持平(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 底部Tab栏) + // 手机端:第六节课底部刚好在悬浮TabBar上方(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 悬浮TabBar - 安全区域) // 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题) const sectionHeight = useMemo((): number => { const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT; - const bottomOffset = isMobile ? TAB_BAR_HEIGHT : 0; + // 悬浮TabBar高度 + 安全区域底部 + const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0; // 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移 const availableHeight = screenHeight - totalHeaderHeight - bottomOffset; // 6节课,每节课高度 = 可用高度 / 6 const calculatedHeight = Math.floor(availableHeight / 6); // 确保高度不低于默认值 return Math.max(DEFAULT_SECTION_HEIGHT, calculatedHeight); - }, [screenHeight, isMobile]); + }, [screenHeight, isMobile, insets.bottom]); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [pendingDay, setPendingDay] = useState(0); const [pendingMergedSection, setPendingMergedSection] = useState(1); @@ -718,7 +720,7 @@ export const ScheduleScreen: React.FC = () => { }; return ( - + {/* 周选择器 */} diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 27a8722..4c15f1d 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -621,11 +621,46 @@ class MessageService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( - '/messages/system/cursor', - { params } - ); - return response.data; + // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页 + const requestParams: Record = { + cursor: params.cursor || '', + page_size: params.page_size, + }; + + const response = await api.get('/messages/system', requestParams); + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + // 游标分页格式 + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + // 传统分页格式 - 转换为游标格式 + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取系统消息列表失败:', error); return { diff --git a/src/services/postService.ts b/src/services/postService.ts index 72afa79..4f81844 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -294,12 +294,57 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/posts', { - cursor: params.cursor, - page_size: params.page_size, - post_type: params.post_type, - }); - return response.data; + // 构建请求参数 + // 后端使用 `tab` 参数区分帖子类型,使用 `cursor` 参数启用游标分页 + // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页 + const requestParams: Record = { + cursor: params.cursor || '', // 空字符串表示首次请求 + page_size: params.page_size, + }; + + // 将 post_type 映射为后端的 tab 参数 + if (params.post_type) { + requestParams.tab = params.post_type; + } + + const response = await api.get('/posts', requestParams); + + const data = response.data; + + // 兼容两种 API 响应格式: + // 1. 游标分页格式:{ items, next_cursor, prev_cursor, has_more } + // 2. 传统分页格式:{ list, total, page, page_size, total_pages } + if (data && typeof data === 'object') { + // 游标分页格式 + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + // 传统分页格式 - 转换为游标格式 + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + // 使用页码作为游标 + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + // 默认返回空数据 + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取帖子列表失败:', error); return { @@ -322,11 +367,41 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/posts/search', { + const response = await api.get('/posts/search', { ...params, keyword: query, }); - return response.data; + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('搜索帖子失败:', error); return { @@ -349,11 +424,41 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( + const response = await api.get( `/users/${userId}/posts`, params ); - return response.data; + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取用户帖子列表失败:', error); return {