feat(home): integrate PagerView for tab navigation and add verification redirect
Replace custom gesture handling with PagerView component for smoother tab switching on home screen. Add verification required redirects in API and WebSocket services that automatically navigate users to the verification guide when receiving VERIFICATION_REQUIRED error codes. BREAKING CHANGE: API and WS services now require router to be set via setExpoRouter/setRouter for navigation to work properly
This commit is contained in:
@@ -10,6 +10,8 @@ import * as Notifications from 'expo-notifications';
|
|||||||
import * as SystemUI from 'expo-system-ui';
|
import * as SystemUI from 'expo-system-ui';
|
||||||
|
|
||||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||||
|
import { api } from '../src/services/api';
|
||||||
|
import { wsService } from '../src/services/wsService';
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
useAppColors,
|
useAppColors,
|
||||||
@@ -164,6 +166,11 @@ function ThemedStack() {
|
|||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
const resolved = useResolvedColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.setExpoRouter(router);
|
||||||
|
wsService.setRouter(router);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
|
|||||||
31
src/components/common/PagerView.tsx
Normal file
31
src/components/common/PagerView.tsx
Normal file
@@ -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<PagerViewNative, PagerViewProps>(
|
||||||
|
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
|
||||||
|
return (
|
||||||
|
<PagerViewNative
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
initialPage={initialPage}
|
||||||
|
onPageSelected={onPageSelected}
|
||||||
|
overdrag={overdrag}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PagerViewNative>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PagerView.displayName = 'PagerView';
|
||||||
|
|
||||||
|
export default PagerView;
|
||||||
27
src/components/common/PagerView.web.tsx
Normal file
27
src/components/common/PagerView.web.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={style}>
|
||||||
|
{childrenArray[0]}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PagerView = React.forwardRef(PagerViewInternal);
|
||||||
|
PagerView.displayName = 'PagerView';
|
||||||
|
|
||||||
|
export default PagerView;
|
||||||
@@ -17,6 +17,7 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
|
|||||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||||
export { default as AppBackButton } from './AppBackButton';
|
export { default as AppBackButton } from './AppBackButton';
|
||||||
|
export { default as PagerView } from './PagerView';
|
||||||
|
|
||||||
// 图片相关组件
|
// 图片相关组件
|
||||||
export { default as SmartImage } from './SmartImage';
|
export { default as SmartImage } from './SmartImage';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter, useFocusEffect } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
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 { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||||
@@ -42,8 +42,6 @@ import * as hrefs from '../../navigation/hrefs';
|
|||||||
const TABS = ['关注', '最新', '热门'];
|
const TABS = ['关注', '最新', '热门'];
|
||||||
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
|
||||||
const SWIPE_COOLDOWN_MS = 300;
|
|
||||||
const MOBILE_TAB_BAR_HEIGHT = 64;
|
const MOBILE_TAB_BAR_HEIGHT = 64;
|
||||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||||
const MOBILE_FAB_GAP = 12;
|
const MOBILE_FAB_GAP = 12;
|
||||||
@@ -213,10 +211,12 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||||
const postIdsRef = useRef<Set<string>>(new Set());
|
const postIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastSwipeAtRef = useRef(0);
|
|
||||||
|
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const pagerRef = useRef<any>(null);
|
||||||
|
|
||||||
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
||||||
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
|
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
|
||||||
@@ -505,12 +505,22 @@ export const HomeScreen: React.FC = () => {
|
|||||||
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换Tab(手势/点击共用)
|
// 切换Tab(点击 TabBar 或手势滑动)
|
||||||
const changeTab = useCallback((nextIndex: number) => {
|
const changeTab = useCallback((nextIndex: number) => {
|
||||||
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveIndex(nextIndex);
|
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]);
|
}, [activeIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -525,35 +535,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
loadChannels();
|
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 = () => {
|
const handleSearchPress = () => {
|
||||||
setShowSearch(true);
|
setShowSearch(true);
|
||||||
@@ -1014,32 +995,52 @@ export const HomeScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 帖子列表 */}
|
{/* 帖子列表 */}
|
||||||
{isMobile ? (
|
{Platform.OS === 'web' ? (
|
||||||
// 移动端:使用 GestureDetector 支持手势切换 Tab
|
|
||||||
<GestureDetector gesture={swipeGesture}>
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
<Loading fullScreen />
|
<Loading fullScreen />
|
||||||
) : viewMode === 'list' ? (
|
) : viewMode === 'list' ? (
|
||||||
renderListContent()
|
renderListContent()
|
||||||
) : (
|
) : (
|
||||||
// 网格模式:使用响应式网格
|
|
||||||
renderResponsiveGrid()
|
renderResponsiveGrid()
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
|
||||||
) : (
|
) : (
|
||||||
// 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击
|
<PagerView
|
||||||
<View style={styles.contentContainer}>
|
ref={pagerRef}
|
||||||
{isInitialLoading ? (
|
style={styles.contentContainer}
|
||||||
|
initialPage={activeIndex}
|
||||||
|
onPageSelected={onPagerPageSelected}
|
||||||
|
overdrag
|
||||||
|
>
|
||||||
|
<View key="follow" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 0 ? (
|
||||||
<Loading fullScreen />
|
<Loading fullScreen />
|
||||||
) : viewMode === 'list' ? (
|
) : activeIndex === 0 ? (
|
||||||
renderListContent()
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
) : (
|
) : (
|
||||||
// 网格模式:使用响应式网格
|
<View style={styles.contentContainer} />
|
||||||
renderResponsiveGrid()
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<View key="latest" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 1 ? (
|
||||||
|
<Loading fullScreen />
|
||||||
|
) : activeIndex === 1 ? (
|
||||||
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
|
) : (
|
||||||
|
<View style={styles.contentContainer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View key="hot" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 2 ? (
|
||||||
|
<Loading fullScreen />
|
||||||
|
) : activeIndex === 2 ? (
|
||||||
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
|
) : (
|
||||||
|
<View style={styles.contentContainer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</PagerView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 漂浮发帖按钮 */}
|
{/* 漂浮发帖按钮 */}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { CommonActions } from '@react-navigation/native';
|
|||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { hrefVerificationGuide } from '../navigation/hrefs';
|
||||||
|
|
||||||
// 生产地址 https://bbs.littlelan.cn
|
// 生产地址 https://bbs.littlelan.cn
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
|
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
|
||||||
@@ -49,11 +51,13 @@ export interface PaginatedData<T> {
|
|||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
errorCode?: string;
|
||||||
|
|
||||||
constructor(code: number, message: string) {
|
constructor(code: number, message: string, errorCode?: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
|
this.errorCode = errorCode;
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +75,8 @@ interface JwtPayload {
|
|||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private navigation: any = null;
|
private navigation: any = null;
|
||||||
|
private expoRouter: any = null;
|
||||||
|
private verificationModalShown = false;
|
||||||
// 刷新锁:防止并发刷新
|
// 刷新锁:防止并发刷新
|
||||||
private refreshLock: Promise<boolean> | null = null;
|
private refreshLock: Promise<boolean> | null = null;
|
||||||
|
|
||||||
@@ -78,6 +84,21 @@ class ApiClient {
|
|||||||
this.baseUrl = baseUrl;
|
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跳转)
|
// 设置导航对象(用于处理401跳转)
|
||||||
setNavigation(navigation: any) {
|
setNavigation(navigation: any) {
|
||||||
this.navigation = navigation;
|
this.navigation = navigation;
|
||||||
@@ -267,7 +288,10 @@ class ApiClient {
|
|||||||
if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) {
|
if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) {
|
||||||
const data = parsedBody as ApiResponse<T>;
|
const data = parsedBody as ApiResponse<T>;
|
||||||
if (data.code !== 0) {
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppState, AppStateStatus } from 'react-native';
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
|
||||||
import { api, WS_URL } from './api';
|
import { api, WS_URL } from './api';
|
||||||
|
import { hrefVerificationGuide } from '../navigation/hrefs';
|
||||||
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
||||||
import { systemNotificationService } from './systemNotificationService';
|
import { systemNotificationService } from './systemNotificationService';
|
||||||
|
|
||||||
@@ -407,11 +408,15 @@ class WebSocketService {
|
|||||||
// 处理错误消息 - emit 到上层处理
|
// 处理错误消息 - emit 到上层处理
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
console.error('WebSocket error:', msg.payload);
|
console.error('WebSocket error:', msg.payload);
|
||||||
|
const errorCode: string = msg.payload?.code || 'unknown';
|
||||||
const err: WSErrorMessage = {
|
const err: WSErrorMessage = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
code: msg.payload?.code || 'unknown',
|
code: errorCode,
|
||||||
message: msg.payload?.message || 'Unknown error',
|
message: msg.payload?.message || 'Unknown error',
|
||||||
};
|
};
|
||||||
|
if (errorCode === 'VERIFICATION_REQUIRED') {
|
||||||
|
this.handleVerificationRequired();
|
||||||
|
}
|
||||||
this.emit('error', err);
|
this.emit('error', err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1117,25 @@ class WebSocketService {
|
|||||||
this.lastActivityAt = Date.now();
|
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 {
|
private startHeartbeat(): void {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user