feat: 同步dev分支并保留当前修改
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export interface CursorPaginationConfig {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
/** 是否启用双向分页 */
|
/** 是否启用双向分页 */
|
||||||
bidirectional?: boolean;
|
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 (
|
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
<View style={styles.headerTitleContainer}>
|
||||||
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
|
<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 && (
|
||||||
|
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
||||||
<Text
|
<Text
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
color={isActive ? colors.text.inverse : colors.text.hint}
|
||||||
style={styles.filterCount}
|
|
||||||
>
|
>
|
||||||
{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 && (
|
||||||
|
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
||||||
<Text
|
<Text
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
color={isActive ? colors.text.inverse : colors.text.hint}
|
||||||
style={styles.filterCount}
|
|
||||||
>
|
>
|
||||||
{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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
{/* 周选择器 */}
|
{/* 周选择器 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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` 参数启用游标分页
|
||||||
|
// 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
cursor: params.cursor || '', // 空字符串表示首次请求
|
||||||
page_size: params.page_size,
|
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) {
|
} 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user