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