feat: 同步dev分支并保留当前修改
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -196,6 +196,8 @@ export interface CursorPaginationConfig {
|
||||
pageSize: number;
|
||||
/** 是否启用双向分页 */
|
||||
bidirectional?: boolean;
|
||||
/** 是否自动加载第一页,默认为 true */
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
{/* 周选择器 */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user