feat: 同步dev分支并保留当前修改
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m35s
Frontend CI / ota-android (push) Successful in 13m3s
Frontend CI / build-android-apk (push) Successful in 39m23s

This commit is contained in:
lafay
2026-03-21 03:22:28 +08:00
parent 2c65330837
commit a8c78f0c3f
16 changed files with 461 additions and 128 deletions

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
CursorPaginationState,
CursorPaginationConfig,
@@ -23,7 +23,7 @@ export function useCursorPagination<T, P = void>(
config: Partial<CursorPaginationConfig> = {},
extraParams?: P
): UseCursorPaginationReturn<T> {
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<T, P = void>(
items: [],
nextCursor: null,
prevCursor: null,
hasMore: false,
hasMore: true, // 初始设为 true以便首次加载可以执行
isLoading: false,
isRefreshing: false,
error: null,
@@ -42,12 +42,28 @@ export function useCursorPagination<T, P = void>(
// 用于取消请求的标志
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<T, P = void>(
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<T, P = void>(
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<T, P = void>(
// 重置状态
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<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 {
...state,
loadMore,

View File

@@ -196,6 +196,8 @@ export interface CursorPaginationConfig {
pageSize: number;
/** 是否启用双向分页 */
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 (
<View style={styles.container}>
{/* 内容区域 */}
@@ -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',

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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(() => {
@@ -338,9 +344,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
) : (
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
</Text>
{unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
</View>
<View style={styles.backButtonPlaceholder} />
</View>
);
@@ -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 (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
activeType === type.key && styles.filterTagActive,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
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}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={activeType === type.key ? colors.primary.main : colors.text.hint}
style={styles.filterCount}
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
@@ -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 (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
activeType === type.key && styles.filterTagActive,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
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}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={activeType === type.key ? colors.primary.main : colors.text.hint}
style={styles.filterCount}
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
@@ -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,

View File

@@ -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,10 +17,14 @@ 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('');
const [sendingCode, setSendingCode] = useState(false);
@@ -330,10 +334,10 @@ export const AccountSecurityScreen: React.FC = () => {
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
)}
</SafeAreaView>
);

View File

@@ -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,16 +16,22 @@ 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<RootStackParamList>;
export const BlockedUsersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { isMobile } = useResponsive();
const insets = useSafeAreaInsets();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [processingUserId, setProcessingUserId] = useState<string | null>(null);
// 底部间距,避免被 TabBar 遮挡
const listBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const loadBlockedUsers = useCallback(async () => {
try {
const response = await authService.getBlockedUsers(1, 100);
@@ -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={
<RefreshControl
refreshing={refreshing}

View File

@@ -102,7 +102,8 @@ export const EditProfileScreen: React.FC = () => {
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 = () => {
</View>
{/* 保存按钮 */}
<View style={[styles.buttonContainer, { marginBottom: bottomSafeDistance }]}>
<View style={[styles.buttonContainer, { marginBottom: scrollBottomInset }]}>
<TouchableOpacity
style={[
styles.saveButton,

View File

@@ -11,7 +11,7 @@ import {
Switch,
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 AsyncStorage from '@react-native-async-storage/async-storage';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
@@ -34,7 +34,11 @@ export const NotificationSettingsScreen: 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 = () => {
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
)}

View File

@@ -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<Post[]>([]);

View File

@@ -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 = () => {
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
)}

View File

@@ -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<NativeStackNavigationProp<ScheduleStackParamList>>();
// 使用响应式 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<Course[]>([]);
@@ -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<number>(0);
const [pendingMergedSection, setPendingMergedSection] = useState<number>(1);
@@ -718,7 +720,7 @@ export const ScheduleScreen: React.FC = () => {
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="light-content" backgroundColor={colors.primary.main} />
{/* 周选择器 */}

View File

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

View File

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