dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
16 changed files with 461 additions and 128 deletions
Showing only changes of commit a8c78f0c3f - Show all commits

View File

@@ -203,8 +203,8 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
alignItems: 'flex-start', alignItems: 'flex-start',
padding: containerPadding, padding: containerPadding,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderBottomWidth: 1, // 移除底部边框,使用卡片式设计
borderBottomColor: colors.divider, marginBottom: 1,
}, },
iconContainer: { iconContainer: {
marginRight: spacing.md, marginRight: spacing.md,

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { import {
CursorPaginationState, CursorPaginationState,
CursorPaginationConfig, CursorPaginationConfig,
@@ -23,7 +23,7 @@ export function useCursorPagination<T, P = void>(
config: Partial<CursorPaginationConfig> = {}, config: Partial<CursorPaginationConfig> = {},
extraParams?: P extraParams?: P
): UseCursorPaginationReturn<T> { ): UseCursorPaginationReturn<T> {
const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config; const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false, autoLoad = true } = config;
// 限制 pageSize 在有效范围内 // 限制 pageSize 在有效范围内
const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
@@ -32,7 +32,7 @@ export function useCursorPagination<T, P = void>(
items: [], items: [],
nextCursor: null, nextCursor: null,
prevCursor: null, prevCursor: null,
hasMore: false, hasMore: true, // 初始设为 true以便首次加载可以执行
isLoading: false, isLoading: false,
isRefreshing: false, isRefreshing: false,
error: null, error: null,
@@ -42,12 +42,28 @@ export function useCursorPagination<T, P = void>(
// 用于取消请求的标志 // 用于取消请求的标志
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
// 用于追踪是否已自动加载
const autoLoadRef = useRef(false);
// 用于追踪是否为首次加载的 ref
const isFirstLoadRef = useRef(true);
// 用于追踪是否正在加载中(防止重复调用)
const isLoadingRef = useRef(false);
// 加载更多(下一页) // 加载更多(下一页)
const loadMore = useCallback(async () => { 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; return;
} }
// 标记正在加载
isLoadingRef.current = true;
cancelledRef.current = false; cancelledRef.current = false;
setState(prev => ({ ...prev, isLoading: true, error: null })); setState(prev => ({ ...prev, isLoading: true, error: null }));
@@ -60,7 +76,13 @@ export function useCursorPagination<T, P = void>(
extraParams, extraParams,
}); });
if (cancelledRef.current) return; if (cancelledRef.current) {
isLoadingRef.current = false;
return;
}
// 标记首次加载完成
isFirstLoadRef.current = false;
setState(prev => ({ setState(prev => ({
...prev, ...prev,
@@ -71,14 +93,21 @@ export function useCursorPagination<T, P = void>(
isLoading: false, isLoading: false,
isFirstLoad: false, isFirstLoad: false,
})); }));
isLoadingRef.current = false;
} catch (error) { } catch (error) {
if (cancelledRef.current) return; if (cancelledRef.current) {
isLoadingRef.current = false;
return;
}
setState(prev => ({ setState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : '加载失败', error: error instanceof Error ? error.message : '加载失败',
})); }));
isLoadingRef.current = false;
} }
}, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]); }, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]);
@@ -163,11 +192,14 @@ export function useCursorPagination<T, P = void>(
// 重置状态 // 重置状态
const reset = useCallback(() => { const reset = useCallback(() => {
cancelledRef.current = true; cancelledRef.current = true;
autoLoadRef.current = false;
isFirstLoadRef.current = true;
isLoadingRef.current = false;
setState({ setState({
items: [], items: [],
nextCursor: null, nextCursor: null,
prevCursor: null, prevCursor: null,
hasMore: false, hasMore: true, // 重置后也设为 true以便可以重新加载
isLoading: false, isLoading: false,
isRefreshing: false, isRefreshing: false,
error: null, error: null,
@@ -195,6 +227,69 @@ export function useCursorPagination<T, P = void>(
[] []
); );
// 自动加载第一页
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<T> = 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 { return {
...state, ...state,
loadMore, loadMore,

View File

@@ -196,6 +196,8 @@ export interface CursorPaginationConfig {
pageSize: number; pageSize: number;
/** 是否启用双向分页 */ /** 是否启用双向分页 */
bidirectional?: boolean; bidirectional?: boolean;
/** 是否自动加载第一页,默认为 true */
autoLoad?: boolean;
} }
/** /**

View File

@@ -269,6 +269,9 @@ export function SimpleMobileTabNavigator() {
); );
}; };
// 计算底部安全距离
const bottomSafeArea = TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2 + insets.bottom;
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 内容区域 */} {/* 内容区域 */}
@@ -331,7 +334,9 @@ const styles = StyleSheet.create({
}, },
content: { content: {
flex: 1, flex: 1,
marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2, },
bottomSpacer: {
backgroundColor: 'transparent',
}, },
tabBar: { tabBar: {
position: 'absolute', position: 'absolute',
@@ -340,8 +345,7 @@ const styles = StyleSheet.create({
height: TAB_BAR_HEIGHT, height: TAB_BAR_HEIGHT,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: 24, borderRadius: 24,
borderTopWidth: 1, // 移除顶部边框,避免与内容之间出现线条
borderTopColor: `${colors.divider}88`,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-around', justifyContent: 'space-around',

View File

@@ -179,8 +179,9 @@ export const HomeScreen: React.FC = () => {
if (!isMobile) { if (!isMobile) {
return undefined; return undefined;
} }
// 往底部导航栏靠近一个按钮的位置 // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT; // TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16
return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom;
}, [isMobile, insets.bottom]); }, [isMobile, insets.bottom]);
// 切换视图模式 // 切换视图模式
@@ -375,6 +376,11 @@ export const HomeScreen: React.FC = () => {
const columns: Post[][] = Array.from({ length: gridColumns }, () => []); const columns: Post[][] = Array.from({ length: gridColumns }, () => []);
const columnHeights: number[] = Array(gridColumns).fill(0); const columnHeights: number[] = Array(gridColumns).fill(0);
// 防御性检查:确保 posts 存在且是数组
if (!posts || !Array.isArray(posts) || posts.length === 0) {
return columns;
}
// 计算单列宽度 // 计算单列宽度
const totalGap = (gridColumns - 1) * responsiveGap; const totalGap = (gridColumns - 1) * responsiveGap;
const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns;

View File

@@ -249,10 +249,12 @@ export const MessageListScreen: React.FC = () => {
}, [width]); }, [width]);
// 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出 // 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出
// TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88
const listBottomInset = useMemo(() => { const listBottomInset = useMemo(() => {
if (isWideScreen) return spacing.lg; if (isWideScreen) return spacing.lg;
return tabBarHeight + insets.bottom + spacing.md; const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距
}, [isWideScreen, tabBarHeight, insets.bottom]); return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
}, [isWideScreen, insets.bottom]);
// 【新架构】页面获得焦点时初始化MessageManager // 【新架构】页面获得焦点时初始化MessageManager
useEffect(() => { useEffect(() => {

View File

@@ -5,7 +5,7 @@
* 支持响应式布局 * 支持响应式布局
*/ */
import React, { useState, useCallback, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
View, View,
FlatList, FlatList,
@@ -20,7 +20,7 @@ import { useIsFocused } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing } from '../../theme'; import { colors, spacing, borderRadius } from '../../theme';
import { SystemMessageResponse } from '../../types/dto'; import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService'; import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService'; import { commentService } from '../../services/commentService';
@@ -75,6 +75,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表 // 【游标分页】使用 useCursorPagination hook 获取系统消息列表
// 使用 useCallback 包裹 fetchFunction避免无限重新创建导致循环
const fetchSystemMessages = useCallback(
async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => {
return await messageService.getSystemMessagesCursor({
cursor,
page_size: pageSize,
});
},
[]
);
const { const {
items: messages, items: messages,
isLoading, isLoading,
@@ -83,15 +94,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
loadMore, loadMore,
refresh, refresh,
error: paginationError, error: paginationError,
} = useCursorPagination( } = useCursorPagination(fetchSystemMessages, { pageSize: 20 });
async ({ cursor, pageSize }) => {
return await messageService.getSystemMessagesCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
// 本地刷新状态仅用于下拉刷新的UI显示 // 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -161,13 +164,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
}, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]);
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读 // 页面加载和获得焦点时刷新,并自动标记所有消息为已读
// 使用 ref 防止重复执行,避免无限循环
const hasInitializedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isFocused) { if (isFocused && !hasInitializedRef.current) {
hasInitializedRef.current = true;
fetchUnreadCount(); fetchUnreadCount();
// 进入界面自动标记所有消息为已读 // 进入界面自动标记所有消息为已读
handleMarkAllRead(); handleMarkAllRead();
} }
}, [isFocused, fetchUnreadCount, handleMarkAllRead]); }, [isFocused]); // 只依赖 isFocused函数使用 ref 稳定引用
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => { useEffect(() => {
@@ -338,9 +344,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
) : ( ) : (
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
)} )}
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}> <View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
</Text>
</Text>
{unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
</View>
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
</View> </View>
); );
@@ -388,30 +401,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
} }
return m.system_type === type.key; return m.system_type === type.key;
}).length; }).length;
const isActive = activeType === type.key;
return ( return (
<TouchableOpacity <TouchableOpacity
key={type.key} key={type.key}
style={[ style={[
styles.filterTag, styles.filterTag,
activeType === type.key && styles.filterTagActive, isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide, isWideScreen && styles.filterTagWide,
]} ]}
onPress={() => setActiveType(type.key)} onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
> >
<Text <Text
variant="body" variant="body"
color={activeType === type.key ? colors.primary.main : colors.text.secondary} color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
> >
{type.title} {type.title}
</Text> </Text>
{count > 0 && ( {count > 0 && (
<Text <View style={[styles.filterCount, isActive && styles.filterCountActive]}>
variant="caption" <Text
color={activeType === type.key ? colors.primary.main : colors.text.hint} variant="caption"
style={styles.filterCount} color={isActive ? colors.text.inverse : colors.text.hint}
> >
{count} {count}
</Text> </Text>
</View>
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -463,29 +480,33 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
} }
return m.system_type === type.key; return m.system_type === type.key;
}).length; }).length;
const isActive = activeType === type.key;
return ( return (
<TouchableOpacity <TouchableOpacity
key={type.key} key={type.key}
style={[ style={[
styles.filterTag, styles.filterTag,
activeType === type.key && styles.filterTagActive, isActive && styles.filterTagActive,
]} ]}
onPress={() => setActiveType(type.key)} onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
> >
<Text <Text
variant="body" variant="body"
color={activeType === type.key ? colors.primary.main : colors.text.secondary} color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
> >
{type.title} {type.title}
</Text> </Text>
{count > 0 && ( {count > 0 && (
<Text <View style={[styles.filterCount, isActive && styles.filterCountActive]}>
variant="caption" <Text
color={activeType === type.key ? colors.primary.main : colors.text.hint} variant="caption"
style={styles.filterCount} color={isActive ? colors.text.inverse : colors.text.hint}
> >
{count} {count}
</Text> </Text>
</View>
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -529,13 +550,70 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: colors.background.default, 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: { filterContainer: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderBottomWidth: 1, // 移除底部边框,让界面更简洁
borderBottomColor: colors.divider,
}, },
filterContainerWideWeb: { filterContainerWideWeb: {
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
@@ -548,55 +626,36 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
marginRight: spacing.sm, marginRight: spacing.xs,
borderRadius: 16, borderRadius: borderRadius.lg,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
}, },
filterTagWide: { filterTagWide: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.md, paddingVertical: spacing.md,
marginRight: spacing.md, marginRight: spacing.sm,
}, },
filterTagActive: { filterTagActive: {
backgroundColor: colors.primary.light + '30', backgroundColor: colors.primary.main,
},
filterTagTextActive: {
fontWeight: '600',
}, },
filterCount: { filterCount: {
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, backgroundColor: colors.primary.light + '40',
// Header 样式 paddingHorizontal: 6,
header: { paddingVertical: 1,
flexDirection: 'row', borderRadius: 8,
minWidth: 18,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
}, },
headerWide: { filterCountActive: {
paddingHorizontal: spacing.xl, backgroundColor: 'rgba(255, 255, 255, 0.25)',
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: { listContent: {
flexGrow: 1, flexGrow: 1,
paddingTop: spacing.sm,
}, },
listContentWide: { listContentWide: {
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,

View File

@@ -7,7 +7,7 @@ import {
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
} from 'react-native'; } 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
@@ -17,10 +17,14 @@ import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
export const AccountSecurityScreen: React.FC = () => { export const AccountSecurityScreen: React.FC = () => {
const { isWideScreen } = useResponsive(); const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets();
const currentUser = useAuthStore((state) => state.currentUser); const currentUser = useAuthStore((state) => state.currentUser);
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser); 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 [email, setEmail] = useState(currentUser?.email || '');
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState('');
const [sendingCode, setSendingCode] = useState(false); const [sendingCode, setSendingCode] = useState(false);
@@ -330,10 +334,10 @@ export const AccountSecurityScreen: React.FC = () => {
<SafeAreaView style={styles.container} edges={['bottom']}> <SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? ( {isWideScreen ? (
<ResponsiveContainer maxWidth={800}> <ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
)} )}
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -8,7 +8,7 @@ import {
View, View,
Alert, Alert,
} from 'react-native'; } 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 { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
@@ -16,16 +16,22 @@ import { authService } from '../../services';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { colors, spacing, borderRadius, shadows } from '../../theme';
import { User } from '../../types'; import { User } from '../../types';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { useResponsive } from '../../hooks';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>; type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const BlockedUsersScreen: React.FC = () => { export const BlockedUsersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const { isMobile } = useResponsive();
const insets = useSafeAreaInsets();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [processingUserId, setProcessingUserId] = useState<string | null>(null); const [processingUserId, setProcessingUserId] = useState<string | null>(null);
// 底部间距,避免被 TabBar 遮挡
const listBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const loadBlockedUsers = useCallback(async () => { const loadBlockedUsers = useCallback(async () => {
try { try {
const response = await authService.getBlockedUsers(1, 100); const response = await authService.getBlockedUsers(1, 100);
@@ -115,7 +121,7 @@ export const BlockedUsersScreen: React.FC = () => {
data={users} data={users}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={renderItem} renderItem={renderItem}
contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]} contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent, { paddingBottom: listBottomInset }]}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}

View File

@@ -102,7 +102,8 @@ export const EditProfileScreen: React.FC = () => {
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false); const [uploadingCover, setUploadingCover] = useState(false);
const [saving, setSaving] = 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; const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16;
@@ -471,7 +472,7 @@ export const EditProfileScreen: React.FC = () => {
</View> </View>
{/* 保存按钮 */} {/* 保存按钮 */}
<View style={[styles.buttonContainer, { marginBottom: bottomSafeDistance }]}> <View style={[styles.buttonContainer, { marginBottom: scrollBottomInset }]}>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.saveButton, styles.saveButton,

View File

@@ -11,7 +11,7 @@ import {
Switch, Switch,
ScrollView, ScrollView,
} from 'react-native'; } 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { colors, spacing, fontSizes, borderRadius } from '../../theme';
@@ -34,7 +34,11 @@ export const NotificationSettingsScreen: React.FC = () => {
const [vibrationEnabled, setVibrationEnabledState] = useState(true); const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true); const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = 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(() => { useEffect(() => {
@@ -150,12 +154,12 @@ export const NotificationSettingsScreen: React.FC = () => {
<SafeAreaView style={styles.container} edges={['bottom']}> <SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? ( {isWideScreen ? (
<ResponsiveContainer maxWidth={800}> <ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()} {renderContent()}
</ScrollView> </ScrollView>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()} {renderContent()}
</ScrollView> </ScrollView>
)} )}

View File

@@ -55,10 +55,12 @@ export const ProfileScreen: React.FC = () => {
const { isDesktop, isTablet, width } = useResponsive(); const { isDesktop, isTablet, width } = useResponsive();
// 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡 // 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡
// TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88
const scrollBottomInset = useMemo(() => { const scrollBottomInset = useMemo(() => {
if (isDesktop) return spacing.lg; if (isDesktop) return spacing.lg;
return tabBarHeight + insets.bottom + spacing.md; const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距
}, [isDesktop, tabBarHeight, insets.bottom]); return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
}, [isDesktop, insets.bottom]);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);

View File

@@ -20,6 +20,7 @@ import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface SettingsItem { interface SettingsItem {
key: string; key: string;
@@ -67,6 +68,11 @@ export const SettingsScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const { isWideScreen } = useResponsive(); 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) => { const handleItemPress = (key: string) => {
@@ -200,12 +206,12 @@ export const SettingsScreen: React.FC = () => {
<SafeAreaView style={styles.container} edges={['bottom']}> <SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? ( {isWideScreen ? (
<ResponsiveContainer maxWidth={800}> <ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()} {renderContent()}
</ScrollView> </ScrollView>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()} {renderContent()}
</ScrollView> </ScrollView>
)} )}

View File

@@ -19,7 +19,7 @@ import {
} from 'react-native'; } from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler'; import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -50,8 +50,8 @@ const DEFAULT_SECTION_HEIGHT = 105;
const WEEK_SELECTOR_HEIGHT = 44; const WEEK_SELECTOR_HEIGHT = 44;
// 星期标题行高度 // 星期标题行高度
const HEADER_HEIGHT = 40; const HEADER_HEIGHT = 40;
// 底部Tab栏高度(用于避让 // 悬浮TabBar高度TabBar高度64 + 浮动间距12*2 = 88
const TAB_BAR_HEIGHT = 80; const FLOATING_TAB_BAR_HEIGHT = 88;
// 大节时间配置(左侧显示 1-6 // 大节时间配置(左侧显示 1-6
const GROUPED_TIME_SLOTS: TimeSlot[] = [ const GROUPED_TIME_SLOTS: TimeSlot[] = [
@@ -150,6 +150,7 @@ export const ScheduleScreen: React.FC = () => {
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
// 使用响应式 hook 检测屏幕尺寸 // 使用响应式 hook 检测屏幕尺寸
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive(); const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
const insets = useSafeAreaInsets();
const isWeb = platform.isWeb; const isWeb = platform.isWeb;
const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK); const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK);
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
@@ -166,18 +167,19 @@ export const ScheduleScreen: React.FC = () => {
}, [screenWidth, isWeb]); }, [screenWidth, isWeb]);
// 动态计算每节课的高度 // 动态计算每节课的高度
// 手机端:第六节课底部刚好跟底部导航栏持平(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 底部Tab // 手机端:第六节课底部刚好在悬浮TabBar上方(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 悬浮TabBar - 安全区域
// 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题) // 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题)
const sectionHeight = useMemo((): number => { const sectionHeight = useMemo((): number => {
const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT; 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; const availableHeight = screenHeight - totalHeaderHeight - bottomOffset;
// 6节课每节课高度 = 可用高度 / 6 // 6节课每节课高度 = 可用高度 / 6
const calculatedHeight = Math.floor(availableHeight / 6); const calculatedHeight = Math.floor(availableHeight / 6);
// 确保高度不低于默认值 // 确保高度不低于默认值
return Math.max(DEFAULT_SECTION_HEIGHT, calculatedHeight); return Math.max(DEFAULT_SECTION_HEIGHT, calculatedHeight);
}, [screenHeight, isMobile]); }, [screenHeight, isMobile, insets.bottom]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [pendingDay, setPendingDay] = useState<number>(0); const [pendingDay, setPendingDay] = useState<number>(0);
const [pendingMergedSection, setPendingMergedSection] = useState<number>(1); const [pendingMergedSection, setPendingMergedSection] = useState<number>(1);
@@ -718,7 +720,7 @@ export const ScheduleScreen: React.FC = () => {
}; };
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="light-content" backgroundColor={colors.primary.main} /> <StatusBar barStyle="light-content" backgroundColor={colors.primary.main} />
{/* 周选择器 */} {/* 周选择器 */}

View File

@@ -621,11 +621,46 @@ class MessageService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<SystemMessageResponse>> { ): Promise<CursorPaginationResponse<SystemMessageResponse>> {
try { try {
const response = await api.get<CursorPaginationResponse<SystemMessageResponse>>( // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页
'/messages/system/cursor', const requestParams: Record<string, any> = {
{ params } cursor: params.cursor || '',
); page_size: params.page_size,
return response.data; };
const response = await api.get<any>('/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) { } catch (error) {
console.error('获取系统消息列表失败:', error); console.error('获取系统消息列表失败:', error);
return { return {

View File

@@ -294,12 +294,57 @@ class PostService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<Post>> { ): Promise<CursorPaginationResponse<Post>> {
try { try {
const response = await api.get<CursorPaginationResponse<Post>>('/posts', { // 构建请求参数
cursor: params.cursor, // 后端使用 `tab` 参数区分帖子类型,使用 `cursor` 参数启用游标分页
page_size: params.page_size, // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页
post_type: params.post_type, const requestParams: Record<string, any> = {
}); cursor: params.cursor || '', // 空字符串表示首次请求
return response.data; page_size: params.page_size,
};
// 将 post_type 映射为后端的 tab 参数
if (params.post_type) {
requestParams.tab = params.post_type;
}
const response = await api.get<any>('/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) { } catch (error) {
console.error('获取帖子列表失败:', error); console.error('获取帖子列表失败:', error);
return { return {
@@ -322,11 +367,41 @@ class PostService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<Post>> { ): Promise<CursorPaginationResponse<Post>> {
try { try {
const response = await api.get<CursorPaginationResponse<Post>>('/posts/search', { const response = await api.get<any>('/posts/search', {
...params, ...params,
keyword: query, 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) { } catch (error) {
console.error('搜索帖子失败:', error); console.error('搜索帖子失败:', error);
return { return {
@@ -349,11 +424,41 @@ class PostService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<Post>> { ): Promise<CursorPaginationResponse<Post>> {
try { try {
const response = await api.get<CursorPaginationResponse<Post>>( const response = await api.get<any>(
`/users/${userId}/posts`, `/users/${userId}/posts`,
params 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) { } catch (error) {
console.error('获取用户帖子列表失败:', error); console.error('获取用户帖子列表失败:', error);
return { return {