diff --git a/app/_layout.tsx b/app/_layout.tsx
index a200dad..936bfc8 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -10,6 +10,8 @@ import * as Notifications from 'expo-notifications';
import * as SystemUI from 'expo-system-ui';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
+import { api } from '../src/services/api';
+import { wsService } from '../src/services/wsService';
import {
ThemeBootstrap,
useAppColors,
@@ -164,6 +166,11 @@ function ThemedStack() {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
+ useEffect(() => {
+ api.setExpoRouter(router);
+ wsService.setRouter(router);
+ }, [router]);
+
return (
<>
diff --git a/src/components/common/PagerView.tsx b/src/components/common/PagerView.tsx
new file mode 100644
index 0000000..c6cc2ae
--- /dev/null
+++ b/src/components/common/PagerView.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { ViewStyle } from 'react-native';
+import PagerViewNative from 'react-native-pager-view';
+
+type PagerViewProps = {
+ style?: ViewStyle;
+ initialPage?: number;
+ onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
+ overdrag?: boolean;
+ children: React.ReactNode;
+};
+
+export const PagerView = React.forwardRef(
+ ({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+PagerView.displayName = 'PagerView';
+
+export default PagerView;
diff --git a/src/components/common/PagerView.web.tsx b/src/components/common/PagerView.web.tsx
new file mode 100644
index 0000000..93ac8bf
--- /dev/null
+++ b/src/components/common/PagerView.web.tsx
@@ -0,0 +1,27 @@
+import React, { ForwardedRef } from 'react';
+import { View, ViewStyle } from 'react-native';
+
+type PagerViewProps = {
+ style?: ViewStyle;
+ initialPage?: number;
+ onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
+ overdrag?: boolean;
+ children: React.ReactNode;
+};
+
+function PagerViewInternal(
+ { style, children }: PagerViewProps,
+ _ref: ForwardedRef<{ setPage: (page: number) => void }>
+) {
+ const childrenArray = React.Children.toArray(children);
+ return (
+
+ {childrenArray[0]}
+
+ );
+}
+
+export const PagerView = React.forwardRef(PagerViewInternal);
+PagerView.displayName = 'PagerView';
+
+export default PagerView;
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
index 04912a7..169018e 100644
--- a/src/components/common/index.ts
+++ b/src/components/common/index.ts
@@ -17,6 +17,7 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
export { default as AppBackButton } from './AppBackButton';
+export { default as PagerView } from './PagerView';
// 图片相关组件
export { default as SmartImage } from './SmartImage';
diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx
index df1a3ef..89337d3 100644
--- a/src/screens/home/HomeScreen.tsx
+++ b/src/screens/home/HomeScreen.tsx
@@ -23,7 +23,7 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
-import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import { PagerView } from '../../components/common';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
@@ -42,8 +42,6 @@ import * as hrefs from '../../navigation/hrefs';
const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
const DEFAULT_PAGE_SIZE = 20;
-const SWIPE_TRANSLATION_THRESHOLD = 40;
-const SWIPE_COOLDOWN_MS = 300;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12;
@@ -211,11 +209,13 @@ export const HomeScreen: React.FC = () => {
}
}, [showCreatePost]);
- // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
+// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
const postIdsRef = useRef>(new Set());
- const lastSwipeAtRef = useRef(0);
-
+
const isLoadingMoreRef = useRef(false);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const pagerRef = useRef(null);
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
@@ -505,12 +505,22 @@ export const HomeScreen: React.FC = () => {
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
};
- // 切换Tab(手势/点击共用)
+ // 切换Tab(点击 TabBar 或手势滑动)
const changeTab = useCallback((nextIndex: number) => {
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
return;
}
setActiveIndex(nextIndex);
+ if (Platform.OS !== 'web') {
+ pagerRef.current?.setPage(nextIndex);
+ }
+ }, [activeIndex]);
+
+ const onPagerPageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
+ const nextIndex = e.nativeEvent.position;
+ if (nextIndex !== activeIndex) {
+ setActiveIndex(nextIndex);
+ }
}, [activeIndex]);
useEffect(() => {
@@ -525,35 +535,6 @@ export const HomeScreen: React.FC = () => {
loadChannels();
}, []);
- const handleSwipeTabChange = useCallback((translationX: number) => {
- setActiveIndex(prev => (
- translationX < 0
- ? Math.min(prev + 1, TABS.length - 1)
- : Math.max(prev - 1, 0)
- ));
- }, []);
-
- const swipeGesture = useMemo(() => (
- Gesture.Pan()
- .runOnJS(true)
- .activeOffsetX([-15, 15])
- .failOffsetY([-20, 20])
- .shouldCancelWhenOutside(true)
- .onEnd((event) => {
- const now = Date.now();
- if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
- return;
- }
-
- if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
- return;
- }
-
- lastSwipeAtRef.current = now;
- handleSwipeTabChange(event.translationX);
- })
- ), [handleSwipeTabChange]);
-
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => {
setShowSearch(true);
@@ -1014,32 +995,52 @@ export const HomeScreen: React.FC = () => {
{/* 帖子列表 */}
- {isMobile ? (
- // 移动端:使用 GestureDetector 支持手势切换 Tab
-
-
- {isInitialLoading ? (
-
- ) : viewMode === 'list' ? (
- renderListContent()
- ) : (
- // 网格模式:使用响应式网格
- renderResponsiveGrid()
- )}
-
-
- ) : (
- // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击
+ {Platform.OS === 'web' ? (
{isInitialLoading ? (
) : viewMode === 'list' ? (
renderListContent()
) : (
- // 网格模式:使用响应式网格
renderResponsiveGrid()
)}
+ ) : (
+
+
+ {isInitialLoading && activeIndex === 0 ? (
+
+ ) : activeIndex === 0 ? (
+ viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
+ ) : (
+
+ )}
+
+
+ {isInitialLoading && activeIndex === 1 ? (
+
+ ) : activeIndex === 1 ? (
+ viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
+ ) : (
+
+ )}
+
+
+ {isInitialLoading && activeIndex === 2 ? (
+
+ ) : activeIndex === 2 ? (
+ viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
+ ) : (
+
+ )}
+
+
)}
{/* 漂浮发帖按钮 */}
diff --git a/src/services/api.ts b/src/services/api.ts
index 74a02a2..98a3253 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -10,6 +10,8 @@ import { CommonActions } from '@react-navigation/native';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
+import { hrefVerificationGuide } from '../navigation/hrefs';
+
// 生产地址 https://bbs.littlelan.cn
const getBaseUrl = () => {
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
@@ -49,11 +51,13 @@ export interface PaginatedData {
export class ApiError extends Error {
code: number;
message: string;
+ errorCode?: string;
- constructor(code: number, message: string) {
+ constructor(code: number, message: string, errorCode?: string) {
super(message);
this.code = code;
this.message = message;
+ this.errorCode = errorCode;
this.name = 'ApiError';
}
}
@@ -71,6 +75,8 @@ interface JwtPayload {
class ApiClient {
private baseUrl: string;
private navigation: any = null;
+ private expoRouter: any = null;
+ private verificationModalShown = false;
// 刷新锁:防止并发刷新
private refreshLock: Promise | null = null;
@@ -78,6 +84,21 @@ class ApiClient {
this.baseUrl = baseUrl;
}
+ setExpoRouter(router: any) {
+ this.expoRouter = router;
+ }
+
+ private handleVerificationRequired() {
+ if (this.verificationModalShown) return;
+ this.verificationModalShown = true;
+ if (this.expoRouter) {
+ this.expoRouter.push(hrefVerificationGuide());
+ }
+ setTimeout(() => {
+ this.verificationModalShown = false;
+ }, 3000);
+ }
+
// 设置导航对象(用于处理401跳转)
setNavigation(navigation: any) {
this.navigation = navigation;
@@ -267,7 +288,10 @@ class ApiClient {
if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) {
const data = parsedBody as ApiResponse;
if (data.code !== 0) {
- throw new ApiError(data.code, data.message || '请求失败');
+ if (parsedBody.error_code === 'VERIFICATION_REQUIRED') {
+ this.handleVerificationRequired();
+ }
+ throw new ApiError(data.code, data.message || '请求失败', parsedBody.error_code);
}
return data;
}
diff --git a/src/services/wsService.ts b/src/services/wsService.ts
index 329b66a..aa10eb1 100644
--- a/src/services/wsService.ts
+++ b/src/services/wsService.ts
@@ -1,6 +1,7 @@
import { AppState, AppStateStatus } from 'react-native';
import { api, WS_URL } from './api';
+import { hrefVerificationGuide } from '../navigation/hrefs';
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
import { systemNotificationService } from './systemNotificationService';
@@ -407,11 +408,15 @@ class WebSocketService {
// 处理错误消息 - emit 到上层处理
if (msg.type === 'error') {
console.error('WebSocket error:', msg.payload);
+ const errorCode: string = msg.payload?.code || 'unknown';
const err: WSErrorMessage = {
type: 'error',
- code: msg.payload?.code || 'unknown',
+ code: errorCode,
message: msg.payload?.message || 'Unknown error',
};
+ if (errorCode === 'VERIFICATION_REQUIRED') {
+ this.handleVerificationRequired();
+ }
this.emit('error', err);
return;
}
@@ -1112,6 +1117,25 @@ class WebSocketService {
this.lastActivityAt = Date.now();
}
+ private verificationModalShown = false;
+
+ private handleVerificationRequired(): void {
+ if (this.verificationModalShown) return;
+ this.verificationModalShown = true;
+ if (this.routerRef) {
+ this.routerRef.push(hrefVerificationGuide());
+ }
+ setTimeout(() => {
+ this.verificationModalShown = false;
+ }, 3000);
+ }
+
+ private routerRef: any = null;
+
+ setRouter(router: any): void {
+ this.routerRef = router;
+ }
+
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {