dev #5
2988
package-lock.json
generated
2988
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
251
src/components/business/QRCodeScanner.tsx
Normal file
251
src/components/business/QRCodeScanner.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Modal,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface QRCodeScannerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 重置扫描状态,确保每次打开都能重新扫描
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
}
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||
if (scanned) return;
|
||||
setScanned(true);
|
||||
|
||||
// 先关闭扫描界面,类似微信的做法
|
||||
onClose();
|
||||
|
||||
// 解析二维码内容
|
||||
// 格式: carrotbbs://qrcode/login?session_id=xxx
|
||||
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||
const sessionId = extractSessionId(data);
|
||||
if (sessionId) {
|
||||
// 跳转到确认页面
|
||||
navigation.navigate('QRCodeConfirm', { sessionId });
|
||||
} else {
|
||||
Alert.alert('无效的二维码', '无法识别该二维码');
|
||||
}
|
||||
} else {
|
||||
Alert.alert('无效的二维码', '请扫描网页端的登录二维码');
|
||||
}
|
||||
};
|
||||
|
||||
const extractSessionId = (url: string): string | null => {
|
||||
try {
|
||||
const sessionIdMatch = url.match(/session_id=([^&]+)/);
|
||||
return sessionIdMatch ? sessionIdMatch[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="#999" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['qr'],
|
||||
}}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<View style={styles.scanArea}>
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.topLeft]} />
|
||||
<View style={[styles.corner, styles.topRight]} />
|
||||
<View style={[styles.corner, styles.bottomLeft]} />
|
||||
<View style={[styles.corner, styles.bottomRight]} />
|
||||
</View>
|
||||
<Text style={styles.scanText}>将二维码放入框内即可扫描</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.flashButton} onPress={() => {}}>
|
||||
<MaterialCommunityIcons name="flashlight" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
scanArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanFrame: {
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
position: 'relative',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 3,
|
||||
},
|
||||
topLeft: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
topRight: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
bottomLeft: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
bottomRight: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
scanText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
flashButton: {
|
||||
padding: spacing.md,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
permissionText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
permissionButton: {
|
||||
marginTop: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default QRCodeScanner;
|
||||
@@ -22,7 +22,7 @@ import { useAuthStore } from '../stores';
|
||||
|
||||
// Deep linking 配置
|
||||
const linking: LinkingOptions<RootStackParamList> = {
|
||||
prefixes: [],
|
||||
prefixes: ['carrotbbs://'],
|
||||
config: {
|
||||
screens: {
|
||||
Auth: {
|
||||
@@ -63,6 +63,7 @@ const linking: LinkingOptions<RootStackParamList> = {
|
||||
CreatePost: 'posts/create',
|
||||
Chat: 'chat/:conversationId',
|
||||
FollowList: 'users/:userId/:type',
|
||||
QRCodeConfirm: 'qrcode/login/:sessionId',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PostDetailScreen } from '../screens/home';
|
||||
import { UserScreen } from '../screens/profile';
|
||||
import FollowListScreen from '../screens/profile/FollowListScreen';
|
||||
import { CreatePostScreen } from '../screens/create';
|
||||
import { QRCodeConfirmScreen } from '../screens/auth/QRCodeConfirmScreen';
|
||||
import {
|
||||
ChatScreen,
|
||||
CreateGroupScreen,
|
||||
@@ -210,6 +211,15 @@ const getAuthenticatedScreens = () => [
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="QRCodeConfirm"
|
||||
name="QRCodeConfirm"
|
||||
component={QRCodeConfirmScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>,
|
||||
];
|
||||
|
||||
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 简单的移动端 Tab Navigator
|
||||
*
|
||||
*
|
||||
* 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现
|
||||
* 这样可以完全避免 React Navigation 状态恢复的问题
|
||||
*/
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
import { colors, shadows } from '../theme';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
@@ -33,6 +34,15 @@ import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsS
|
||||
// ==================== 类型定义 ====================
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
|
||||
// Schedule Stack 类型定义
|
||||
export type ScheduleStackParamList = {
|
||||
Schedule: undefined;
|
||||
CourseDetail: { course: any; relatedCourses?: any[] };
|
||||
};
|
||||
|
||||
// ==================== Stack Navigators ====================
|
||||
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
|
||||
|
||||
// ==================== 常量 ====================
|
||||
const TAB_BAR_HEIGHT = 64;
|
||||
const TAB_BAR_MARGIN = 14;
|
||||
@@ -41,19 +51,32 @@ const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
// ==================== 组件 ====================
|
||||
|
||||
/**
|
||||
* 简化的 Stack Navigator 包装
|
||||
* Schedule Stack Navigator 组件
|
||||
*/
|
||||
function SimpleStackNavigator({
|
||||
children,
|
||||
screenName
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
}) {
|
||||
function ScheduleStackNavigatorComponent() {
|
||||
return (
|
||||
<View style={styles.stackContainer}>
|
||||
{children}
|
||||
</View>
|
||||
<ScheduleStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<ScheduleStack.Screen
|
||||
name="Schedule"
|
||||
component={ScheduleScreen}
|
||||
options={{
|
||||
title: '课表',
|
||||
}}
|
||||
/>
|
||||
<ScheduleStack.Screen
|
||||
name="CourseDetail"
|
||||
component={CourseDetailScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</ScheduleStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +106,7 @@ export function SimpleMobileTabNavigator() {
|
||||
case 'MessageTab':
|
||||
return <MessageListScreen />;
|
||||
case 'ScheduleTab':
|
||||
return <ScheduleScreen />;
|
||||
return <ScheduleStackNavigatorComponent />;
|
||||
case 'ProfileTab':
|
||||
return <ProfileScreen />;
|
||||
default:
|
||||
|
||||
@@ -106,6 +106,7 @@ export type RootStackParamList = {
|
||||
userName?: string;
|
||||
userAvatar?: string | null;
|
||||
};
|
||||
QRCodeConfirm: { sessionId: string };
|
||||
};
|
||||
|
||||
// ==================== 全局类型声明 ====================
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# useResponsive 拆分迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks,以提高代码的可维护性和性能。
|
||||
|
||||
## 新目录结构
|
||||
|
||||
```
|
||||
src/presentation/hooks/responsive/
|
||||
├── types.ts # 共享类型定义
|
||||
├── useBreakpoint.ts # 断点检测
|
||||
├── useScreenSize.ts # 屏幕尺寸
|
||||
├── useOrientation.ts # 方向检测
|
||||
├── usePlatform.ts # 平台检测
|
||||
├── useResponsiveValue.ts # 响应式值映射
|
||||
├── useResponsiveSpacing.ts # 响应式间距
|
||||
├── useColumnCount.ts # 列数计算
|
||||
├── useMediaQuery.ts # 媒体查询
|
||||
├── useBreakpointCheck.ts # 断点范围检查 (兼容层)
|
||||
├── useResponsive.ts # 兼容层 (保持向后兼容)
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
## 新 Hook API 说明
|
||||
|
||||
### useBreakpoint - 断点检测
|
||||
|
||||
```typescript
|
||||
import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive';
|
||||
|
||||
// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide'
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
||||
const fineBreakpoint = useFineBreakpoint();
|
||||
```
|
||||
|
||||
### useScreenSize - 屏幕尺寸
|
||||
|
||||
```typescript
|
||||
import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive';
|
||||
|
||||
// 完整屏幕尺寸信息
|
||||
const {
|
||||
width, height,
|
||||
breakpoint, fineBreakpoint,
|
||||
isMobile, isTablet, isDesktop, isWide
|
||||
} = useScreenSize();
|
||||
|
||||
// 仅获取窗口尺寸
|
||||
const { width, height, scale, fontScale } = useWindowDimensions();
|
||||
```
|
||||
|
||||
### useOrientation - 方向检测
|
||||
|
||||
```typescript
|
||||
import { useOrientation } from '../presentation/hooks/responsive';
|
||||
|
||||
const { orientation, isPortrait, isLandscape } = useOrientation();
|
||||
```
|
||||
|
||||
### usePlatform - 平台检测
|
||||
|
||||
```typescript
|
||||
import { usePlatform } from '../presentation/hooks/responsive';
|
||||
|
||||
const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform();
|
||||
```
|
||||
|
||||
### useResponsiveValue - 响应式值
|
||||
|
||||
```typescript
|
||||
import { useResponsiveValue } from '../presentation/hooks/responsive';
|
||||
|
||||
// 根据断点返回不同值
|
||||
const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
|
||||
|
||||
// 响应式样式
|
||||
const style = useResponsiveStyle({
|
||||
padding: { xs: 8, md: 16, lg: 24 },
|
||||
fontSize: { xs: 14, lg: 16 }
|
||||
});
|
||||
```
|
||||
|
||||
### useResponsiveSpacing - 响应式间距
|
||||
|
||||
```typescript
|
||||
import { useResponsiveSpacing } from '../presentation/hooks/responsive';
|
||||
|
||||
const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
|
||||
```
|
||||
|
||||
### useColumnCount - 列数计算
|
||||
|
||||
```typescript
|
||||
import { useColumnCount } from '../presentation/hooks/responsive';
|
||||
|
||||
const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 });
|
||||
```
|
||||
|
||||
### useBreakpointGTE / useBreakpointLT - 断点范围检查
|
||||
|
||||
```typescript
|
||||
import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive';
|
||||
|
||||
const isMediumUp = useBreakpointGTE('md');
|
||||
const isMobileOnly = useBreakpointLT('lg');
|
||||
const isTabletRange = useBreakpointBetween('md', 'lg');
|
||||
```
|
||||
|
||||
### useMediaQuery - 媒体查询
|
||||
|
||||
```typescript
|
||||
import { useMediaQuery } from '../presentation/hooks/responsive';
|
||||
|
||||
const isMinWidth768 = useMediaQuery({ minWidth: 768 });
|
||||
const isPortrait = useMediaQuery({ orientation: 'portrait' });
|
||||
```
|
||||
|
||||
## 迁移示例
|
||||
|
||||
### 示例 1: 使用 useResponsive (旧)
|
||||
|
||||
```typescript
|
||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks';
|
||||
|
||||
function Component() {
|
||||
const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive();
|
||||
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
|
||||
|
||||
return <View style={{ padding }} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 2: 使用新专注 hooks (推荐)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
useScreenSize,
|
||||
useResponsiveSpacing
|
||||
} from '../presentation/hooks/responsive';
|
||||
|
||||
function Component() {
|
||||
const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize();
|
||||
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
|
||||
|
||||
return <View style={{ padding }} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 3: 仅使用部分功能 (性能优化)
|
||||
|
||||
```typescript
|
||||
import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive';
|
||||
|
||||
function Component() {
|
||||
// 只订阅断点变化,不订阅尺寸变化
|
||||
const breakpoint = useBreakpoint();
|
||||
const { isIOS } = usePlatform(); // 平台不会变化,只计算一次
|
||||
|
||||
return <View />;
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优势
|
||||
|
||||
1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染
|
||||
2. **细粒度更新**: 每个 hook 独立管理状态
|
||||
3. **平台常量**: usePlatform 返回常量,不会触发重渲染
|
||||
|
||||
## 向后兼容
|
||||
|
||||
原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`:
|
||||
|
||||
```typescript
|
||||
import { useResponsive } from '../hooks'; // 仍然可用
|
||||
|
||||
function Component() {
|
||||
const { width, height, isMobile, platform } = useResponsive(); // 仍然可用
|
||||
return <View />;
|
||||
}
|
||||
```
|
||||
|
||||
## 工具函数
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getBreakpoint,
|
||||
getFineBreakpoint,
|
||||
isBreakpointGTE,
|
||||
isBreakpointLT
|
||||
} from '../presentation/hooks/responsive';
|
||||
|
||||
// 非 hooks 版本,用于工具函数
|
||||
const breakpoint = getBreakpoint(800);
|
||||
const fineBreakpoint = getFineBreakpoint(800);
|
||||
const isGTE = isBreakpointGTE('md', 'sm');
|
||||
```
|
||||
|
||||
## 类型导出
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
BreakpointKey,
|
||||
FineBreakpointKey,
|
||||
ResponsiveValue,
|
||||
Orientation,
|
||||
PlatformInfo,
|
||||
ScreenSize,
|
||||
MediaQueryOptions,
|
||||
ResponsiveInfo, // 兼容层
|
||||
} from '../presentation/hooks/responsive';
|
||||
```
|
||||
|
||||
## 常量
|
||||
|
||||
```typescript
|
||||
import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive';
|
||||
|
||||
// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 }
|
||||
// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 }
|
||||
```
|
||||
@@ -188,7 +188,7 @@ export const LoginScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 品牌名称 - 优化大屏排版 */}
|
||||
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>胡萝卜</Text>
|
||||
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>萝卜社区</Text>
|
||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||
|
||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||
@@ -431,7 +431,7 @@ export const LoginScreen: React.FC = () => {
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.appName, { fontSize: appNameSize }]}>胡萝卜</Text>
|
||||
<Text style={[styles.appName, { fontSize: appNameSize }]}>萝卜社区</Text>
|
||||
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||
</Animated.View>
|
||||
|
||||
|
||||
288
src/screens/auth/QRCodeConfirmScreen.tsx
Normal file
288
src/screens/auth/QRCodeConfirmScreen.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { qrcodeApi } from '../../services/authService';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
|
||||
type QRCodeConfirmRouteProp = RouteProp<RootStackParamList, 'QRCodeConfirm'>;
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export const QRCodeConfirmScreen: React.FC = () => {
|
||||
const route = useRoute<QRCodeConfirmRouteProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const { sessionId } = route.params;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 扫码
|
||||
handleScan();
|
||||
}, []);
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await qrcodeApi.scan(sessionId);
|
||||
setUserInfo(response.user);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || '扫码失败';
|
||||
setError(errorMsg);
|
||||
Alert.alert('扫码失败', errorMsg, [
|
||||
{ text: '确定', onPress: () => navigation.goBack() }
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await qrcodeApi.confirm(sessionId);
|
||||
Alert.alert('登录成功', '网页端已登录', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() }
|
||||
]);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || '确认登录失败';
|
||||
Alert.alert('登录失败', errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await qrcodeApi.cancel(sessionId);
|
||||
} catch (err) {
|
||||
// 忽略取消错误
|
||||
}
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
if (loading && !userInfo) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
<Text style={styles.loadingText}>正在扫码...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.backButtonText}>返回</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>确认登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>您正在登录网页端</Text>
|
||||
|
||||
{userInfo && (
|
||||
<View style={styles.userInfo}>
|
||||
{userInfo.avatar ? (
|
||||
<Image source={{ uri: userInfo.avatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<MaterialCommunityIcons name="account" size={40} color="#999" />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.nickname}>{userInfo.nickname}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.confirmButton]}
|
||||
onPress={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.confirmButtonText}>确认登录</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#666',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
userInfo: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
nickname: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
confirmButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: '#666',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#666',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
errorText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
backButton: {
|
||||
marginTop: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
backButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default QRCodeConfirmScreen;
|
||||
@@ -268,8 +268,8 @@ export const RegisterScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 品牌名称 - 优化大屏排版 */}
|
||||
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}>创建账号</Text>
|
||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>加入胡萝卜,开始你的旅程</Text>
|
||||
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}>加入萝卜社区</Text>
|
||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>加入萝卜社区,发现有趣的内容</Text>
|
||||
|
||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
||||
@@ -720,8 +720,8 @@ export const RegisterScreen: React.FC = () => {
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>加入萝卜社区</Text>
|
||||
<Text style={styles.subtitle}>加入萝卜社区,发现有趣的内容</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* 表单 */}
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
* - ChatInput.tsx: 输入框组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
@@ -224,6 +224,7 @@ export const ChatScreen: React.FC = () => {
|
||||
formatTime={formatTime}
|
||||
shouldShowTime={shouldShowTime}
|
||||
onImagePress={handleImagePress}
|
||||
onReply={handleReplyMessage}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -268,25 +269,23 @@ export const ChatScreen: React.FC = () => {
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
<FlashList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => String(item.id)}
|
||||
contentContainerStyle={listContentStyle}
|
||||
keyExtractor={(item: any) => String(item.id)}
|
||||
contentContainerStyle={listContentStyle as any}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshing={loadingMore}
|
||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
||||
progressViewOffset={0}
|
||||
onContentSizeChange={handleMessageListContentSizeChange}
|
||||
// @ts-ignore FlashList 类型定义问题:estimatedItemSize 是必需属性但类型定义缺失
|
||||
estimatedItemSize={80}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: undefined,
|
||||
} as any}
|
||||
onScroll={handleMessageListScroll}
|
||||
scrollEventThrottle={16}
|
||||
// 优化渲染性能
|
||||
initialNumToRender={15}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { getUserCache } from '../../services/database';
|
||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
// 导入扫码组件
|
||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
@@ -182,6 +184,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||
const [scannerVisible, setScannerVisible] = useState(false);
|
||||
|
||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
@@ -356,7 +359,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
setActionMenuVisible(true);
|
||||
};
|
||||
|
||||
const runMenuAction = (action: 'join' | 'create' | 'readAll') => {
|
||||
const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => {
|
||||
setActionMenuVisible(false);
|
||||
if (action === 'join') {
|
||||
handleJoinGroup();
|
||||
@@ -366,6 +369,10 @@ export const MessageListScreen: React.FC = () => {
|
||||
handleCreateGroup();
|
||||
return;
|
||||
}
|
||||
if (action === 'scanQRCode') {
|
||||
setScannerVisible(true);
|
||||
return;
|
||||
}
|
||||
handleMarkAllRead();
|
||||
};
|
||||
|
||||
@@ -911,6 +918,10 @@ export const MessageListScreen: React.FC = () => {
|
||||
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
|
||||
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
|
||||
<View style={styles.actionMenu}>
|
||||
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}>
|
||||
<MaterialCommunityIcons name="qrcode-scan" size={21} color="#444" />
|
||||
<Text style={styles.actionMenuText}>扫码登录</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
|
||||
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" />
|
||||
<Text style={styles.actionMenuText}>加入群聊</Text>
|
||||
@@ -980,6 +991,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
renderConversationList()
|
||||
)}
|
||||
{renderActionMenu()}
|
||||
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 支持响应式布局(宽屏下优化显示)
|
||||
*/
|
||||
|
||||
import React, { useRef, useMemo } from 'react';
|
||||
import React, { useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -22,6 +22,14 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
||||
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
||||
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
||||
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||
import {
|
||||
getBubbleBorderRadius,
|
||||
getMessageGroupPosition,
|
||||
shouldShowSenderInfo,
|
||||
bubbleColors,
|
||||
bubbleStyles,
|
||||
} from './bubbleStyles';
|
||||
|
||||
// 获取屏幕宽度
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -50,6 +58,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
onImagePress,
|
||||
onReply,
|
||||
}) => {
|
||||
const bubbleRef = useRef<View>(null);
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
@@ -343,6 +352,13 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 处理滑动回复
|
||||
const handleSwipeReply = useCallback(() => {
|
||||
if (onReply) {
|
||||
onReply(message);
|
||||
}
|
||||
}, [onReply, message]);
|
||||
|
||||
// 系统通知消息渲染
|
||||
if (isSystemNotice) {
|
||||
return (
|
||||
@@ -361,7 +377,8 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// 消息内容渲染
|
||||
const messageContent = (
|
||||
<TouchableOpacity
|
||||
onPressIn={handlePressIn}
|
||||
onLongPress={handleLongPress}
|
||||
@@ -444,6 +461,17 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 使用 SwipeableMessageBubble 包裹消息内容
|
||||
return (
|
||||
<SwipeableMessageBubble
|
||||
isMe={isMe}
|
||||
onReply={handleSwipeReply}
|
||||
enabled={!!onReply}
|
||||
>
|
||||
{messageContent}
|
||||
</SwipeableMessageBubble>
|
||||
);
|
||||
};
|
||||
|
||||
// Segment 消息的特殊样式 - 自适应宽度
|
||||
@@ -460,4 +488,16 @@ const segmentStyles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageBubble;
|
||||
// 使用 React.memo 优化渲染性能
|
||||
export default React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||
// 自定义比较逻辑:只比较关键属性
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.selectedMessageId === nextProps.selectedMessageId &&
|
||||
prevProps.currentUserId === nextProps.currentUserId &&
|
||||
prevProps.isGroupChat === nextProps.isGroupChat &&
|
||||
prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq &&
|
||||
prevProps.index === nextProps.index
|
||||
);
|
||||
});
|
||||
|
||||
@@ -290,6 +290,7 @@ const ImageSegment: React.FC<{
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
priority="normal"
|
||||
transition={200}
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 可滑动的消息气泡容器
|
||||
* 实现滑动回复手势功能 - 严格单向滑动,无抖动
|
||||
*/
|
||||
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { View, StyleSheet, Animated } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { colors } from '../../../../theme';
|
||||
|
||||
// 滑动阈值
|
||||
const SWIPE_THRESHOLD = 30;
|
||||
const MAX_SWIPE_DISTANCE = 50;
|
||||
|
||||
interface SwipeableMessageBubbleProps {
|
||||
children: React.ReactNode;
|
||||
isMe: boolean;
|
||||
onReply: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||
children,
|
||||
isMe,
|
||||
onReply,
|
||||
enabled = true,
|
||||
}) => {
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const hasTriggeredRef = useRef(false);
|
||||
|
||||
// 处理手势事件 - 使用原生驱动,不干预值
|
||||
const onGestureEvent = Animated.event(
|
||||
[{ nativeEvent: { translationX: translateX } }],
|
||||
{ useNativeDriver: true }
|
||||
);
|
||||
|
||||
// 处理手势状态变化
|
||||
const onHandlerStateChange = useCallback((event: any) => {
|
||||
const { nativeEvent } = event;
|
||||
|
||||
if (nativeEvent.state === State.END) {
|
||||
const translationX = nativeEvent.translationX;
|
||||
|
||||
// 只允许特定方向的滑动
|
||||
const shouldTrigger = isMe
|
||||
? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑
|
||||
: translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑
|
||||
|
||||
if (shouldTrigger && !hasTriggeredRef.current) {
|
||||
hasTriggeredRef.current = true;
|
||||
|
||||
// 触觉反馈
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
// 立即触发回复
|
||||
onReply();
|
||||
|
||||
// 重置标记
|
||||
setTimeout(() => {
|
||||
hasTriggeredRef.current = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 立即回到原位
|
||||
translateX.setValue(0);
|
||||
}
|
||||
}, [isMe, onReply]);
|
||||
|
||||
// 计算回复图标的透明度 - 只在允许的方向显示
|
||||
const iconOpacity = translateX.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10]
|
||||
: [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE],
|
||||
outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
// 计算消息的位移 - 限制在允许的方向
|
||||
const clampedTranslateX = translateX.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10]
|
||||
: [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10],
|
||||
outputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0]
|
||||
: [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
// 如果禁用手势,直接返回子元素
|
||||
if (!enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 回复图标 - 在消息后面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.replyIconContainer,
|
||||
isMe ? styles.replyIconLeft : styles.replyIconRight,
|
||||
{
|
||||
opacity: iconOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="reply"
|
||||
size={20}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* 可滑动的消息内容 */}
|
||||
<PanGestureHandler
|
||||
onGestureEvent={onGestureEvent}
|
||||
onHandlerStateChange={onHandlerStateChange}
|
||||
activeOffsetX={isMe ? [-10, 9999] : [-9999, 10]}
|
||||
failOffsetY={[-20, 20]}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ translateX: clampedTranslateX }],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
replyIconContainer: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
marginTop: -15,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
replyIconLeft: {
|
||||
left: 8,
|
||||
},
|
||||
replyIconRight: {
|
||||
right: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default SwipeableMessageBubble;
|
||||
270
src/screens/message/components/ChatScreen/bubbleStyles.ts
Normal file
270
src/screens/message/components/ChatScreen/bubbleStyles.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 消息气泡样式工具
|
||||
* 参考 Element X 设计,实现动态圆角和现代化气泡样式
|
||||
*/
|
||||
|
||||
import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { colors, spacing } from '../../../../theme';
|
||||
|
||||
// 气泡圆角大小(参考 Element X: 12pt)
|
||||
export const BUBBLE_RADIUS = 16;
|
||||
|
||||
// 气泡颜色方案(参考 Element X 的语义化颜色)
|
||||
export const bubbleColors = {
|
||||
// 自己发送的消息
|
||||
outgoing: {
|
||||
background: '#E8E8E8', // Element X 风格:灰色系
|
||||
text: '#1A1A1A',
|
||||
},
|
||||
// 收到的消息
|
||||
incoming: {
|
||||
background: '#FFFFFF',
|
||||
text: '#1A1A1A',
|
||||
},
|
||||
// 被回复的消息高亮
|
||||
replyHighlight: {
|
||||
background: 'rgba(255, 107, 53, 0.08)',
|
||||
borderLeft: '#FF6B35',
|
||||
},
|
||||
};
|
||||
|
||||
// 消息在组中的位置类型
|
||||
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
|
||||
|
||||
/**
|
||||
* 根据消息在组中的位置获取圆角样式
|
||||
* 参考 Element X 的动态圆角逻辑
|
||||
*/
|
||||
export const getBubbleBorderRadius = (
|
||||
isMe: boolean,
|
||||
position: MessageGroupPosition
|
||||
): ViewStyle => {
|
||||
const radius = BUBBLE_RADIUS;
|
||||
|
||||
if (isMe) {
|
||||
// 自己发送的消息
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4, // 尖角在右下
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 收到的消息
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: 4, // 尖角在左上
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断消息在组中的位置
|
||||
*/
|
||||
export const getMessageGroupPosition = (
|
||||
index: number,
|
||||
messages: Array<{ sender_id: string }>,
|
||||
currentUserId: string
|
||||
): MessageGroupPosition => {
|
||||
const currentMessage = messages[index];
|
||||
if (!currentMessage) return 'single';
|
||||
|
||||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||||
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
|
||||
|
||||
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
|
||||
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
|
||||
|
||||
if (!isSameSenderAsPrev && !isSameSenderAsNext) {
|
||||
return 'single';
|
||||
} else if (!isSameSenderAsPrev && isSameSenderAsNext) {
|
||||
return 'first';
|
||||
} else if (isSameSenderAsPrev && isSameSenderAsNext) {
|
||||
return 'middle';
|
||||
} else {
|
||||
return 'last';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否显示发送者信息(只在组的第一条消息显示)
|
||||
*/
|
||||
export const shouldShowSenderInfo = (
|
||||
index: number,
|
||||
messages: Array<{ sender_id: string }>,
|
||||
): boolean => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const currentMessage = messages[index];
|
||||
const prevMessage = messages[index - 1];
|
||||
|
||||
return prevMessage.sender_id !== currentMessage.sender_id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取气泡基础样式
|
||||
*/
|
||||
export const getBubbleBaseStyle = (isMe: boolean): ViewStyle => ({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 4,
|
||||
minWidth: 60,
|
||||
maxWidth: '75%',
|
||||
backgroundColor: isMe ? bubbleColors.outgoing.background : bubbleColors.incoming.background,
|
||||
// Element X 风格的微妙阴影
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取文本样式
|
||||
*/
|
||||
export const getBubbleTextStyle = (isMe: boolean): TextStyle => ({
|
||||
color: isMe ? bubbleColors.outgoing.text : bubbleColors.incoming.text,
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
});
|
||||
|
||||
/**
|
||||
* 消息气泡样式集合
|
||||
*/
|
||||
export const bubbleStyles = StyleSheet.create({
|
||||
// 基础气泡
|
||||
bubble: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 4,
|
||||
minWidth: 60,
|
||||
},
|
||||
|
||||
// 自己发送的消息
|
||||
outgoing: {
|
||||
backgroundColor: bubbleColors.outgoing.background,
|
||||
},
|
||||
|
||||
// 收到的消息
|
||||
incoming: {
|
||||
backgroundColor: bubbleColors.incoming.background,
|
||||
},
|
||||
|
||||
// 文本样式
|
||||
text: {
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
// 阴影样式(Element X 风格)
|
||||
shadow: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
// 长按反馈阴影
|
||||
longPressShadow: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
|
||||
// 被回复消息的高亮边框
|
||||
replyHighlight: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
backgroundColor: bubbleColors.replyHighlight.background,
|
||||
},
|
||||
|
||||
// 已撤回消息
|
||||
recalled: {
|
||||
backgroundColor: '#F5F7FA',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E8E8E8',
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 12,
|
||||
},
|
||||
|
||||
// 系统通知
|
||||
systemNotice: {
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
systemNoticeText: {
|
||||
fontSize: 12,
|
||||
color: '#8E8E93',
|
||||
backgroundColor: 'rgba(142, 142, 147, 0.12)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
BUBBLE_RADIUS,
|
||||
bubbleColors,
|
||||
getBubbleBorderRadius,
|
||||
getMessageGroupPosition,
|
||||
shouldShowSenderInfo,
|
||||
getBubbleBaseStyle,
|
||||
getBubbleTextStyle,
|
||||
bubbleStyles,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ export { ChatHeader } from './ChatHeader';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { GroupInfoPanel } from './GroupInfoPanel';
|
||||
export { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||
|
||||
// Segment 渲染组件
|
||||
export {
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface MessageBubbleProps {
|
||||
shouldShowTime: (index: number) => boolean;
|
||||
// 图片点击回调
|
||||
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
||||
// 滑动回复回调
|
||||
onReply?: (message: GroupMessage) => void;
|
||||
}
|
||||
|
||||
// 输入框 Props
|
||||
@@ -180,6 +182,14 @@ export interface ReplyPreviewProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// 可滑动消息气泡 Props
|
||||
export interface SwipeableMessageBubbleProps {
|
||||
children: React.ReactNode;
|
||||
isMe: boolean;
|
||||
onReply: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ChatScreen 状态接口
|
||||
export interface ChatScreenState {
|
||||
// 基础状态
|
||||
|
||||
@@ -390,6 +390,16 @@ export const useChatScreen = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转
|
||||
shouldAutoScrollOnEnterRef.current = false;
|
||||
// 清除所有待执行的自动滚动定时器
|
||||
autoScrollTimersRef.current.forEach(clearTimeout);
|
||||
autoScrollTimersRef.current = [];
|
||||
|
||||
// 保存加载前的滚动位置和内容高度
|
||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
||||
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
@@ -400,6 +410,23 @@ export const useChatScreen = () => {
|
||||
const minSeq = Math.min(...messages.map(m => m.seq));
|
||||
setFirstSeq(minSeq);
|
||||
}
|
||||
|
||||
// 加载完成后,恢复滚动位置
|
||||
// 使用 setTimeout 确保 FlashList 已经更新
|
||||
setTimeout(() => {
|
||||
if (flatListRef.current) {
|
||||
// 计算新的滚动位置,保持相对位置不变
|
||||
const newContentHeight = scrollPositionRef.current.contentHeight;
|
||||
const heightDiff = newContentHeight - contentHeightBefore;
|
||||
const newScrollY = scrollYBefore + heightDiff;
|
||||
|
||||
// 滚动到计算后的位置
|
||||
flatListRef.current.scrollToOffset({
|
||||
offset: newScrollY,
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('加载历史消息失败:', error);
|
||||
} finally {
|
||||
@@ -409,13 +436,19 @@ export const useChatScreen = () => {
|
||||
|
||||
// 列表内容尺寸变化后触发首次自动滚动
|
||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
// 如果是加载更多历史消息,不要自动滚动
|
||||
if (loadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldAutoScrollOnEnterRef.current) return;
|
||||
if (loading || loadingMore) return;
|
||||
if (loading) return;
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const prevContentHeight = scrollPositionRef.current.contentHeight;
|
||||
scrollPositionRef.current.contentHeight = contentHeight;
|
||||
|
||||
// 如果内容高度增加(加载了更多消息),不要自动滚动到底部
|
||||
if (prevContentHeight > 0 && contentHeight > prevContentHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import Constants from 'expo-constants';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
@@ -30,6 +31,9 @@ interface SettingsItem {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// 获取应用版本号
|
||||
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
|
||||
|
||||
// 设置分组配置
|
||||
const SETTINGS_GROUPS = [
|
||||
{
|
||||
@@ -53,7 +57,7 @@ const SETTINGS_GROUPS = [
|
||||
title: '关于与帮助',
|
||||
icon: 'information-outline',
|
||||
items: [
|
||||
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' },
|
||||
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
|
||||
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
|
||||
],
|
||||
},
|
||||
@@ -183,7 +187,7 @@ export const SettingsScreen: React.FC = () => {
|
||||
{/* 底部版权信息 */}
|
||||
<View style={styles.footer}>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
萝卜社区 v1.0.2
|
||||
萝卜社区 v{APP_VERSION}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
|
||||
© 2024 Carrot BBS. All rights reserved.
|
||||
|
||||
@@ -432,5 +432,63 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 二维码登录相关接口 ──
|
||||
|
||||
export interface QRCodeSession {
|
||||
session_id: string;
|
||||
qrcode_url: string;
|
||||
expires_in: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface ScanResponse {
|
||||
user: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 二维码登录 API(扩展 AuthService)
|
||||
export const qrcodeApi = {
|
||||
/**
|
||||
* 获取二维码
|
||||
*/
|
||||
getQRCode: async (): Promise<QRCodeSession> => {
|
||||
const response = await api.get<QRCodeSession>('/auth/qrcode');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 扫描二维码
|
||||
*/
|
||||
scan: async (sessionId: string): Promise<ScanResponse> => {
|
||||
const response = await api.post<ScanResponse>('/auth/qrcode/scan', {
|
||||
session_id: sessionId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 确认登录
|
||||
*/
|
||||
confirm: async (sessionId: string): Promise<{ success: boolean }> => {
|
||||
const response = await api.post<{ success: boolean }>('/auth/qrcode/confirm', {
|
||||
session_id: sessionId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消登录
|
||||
*/
|
||||
cancel: async (sessionId: string): Promise<{ success: boolean }> => {
|
||||
const response = await api.post<{ success: boolean }>('/auth/qrcode/cancel', {
|
||||
session_id: sessionId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// 导出认证服务实例
|
||||
export const authService = new AuthService();
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* 乐观更新工具函数测试
|
||||
*/
|
||||
|
||||
import { optimisticUpdate, simpleOptimisticUpdate, createOptimisticUpdaterFactory } from '../optimisticUpdate';
|
||||
|
||||
// 模拟数据
|
||||
interface TestState {
|
||||
posts: Array<{ id: string; likes: number; isLiked: boolean }>;
|
||||
users: Array<{ id: string; isFollowing: boolean }>;
|
||||
}
|
||||
|
||||
const initialState: TestState = {
|
||||
posts: [
|
||||
{ id: '1', likes: 10, isLiked: false },
|
||||
{ id: '2', likes: 5, isLiked: true },
|
||||
],
|
||||
users: [
|
||||
{ id: 'user1', isFollowing: false },
|
||||
{ id: 'user2', isFollowing: true },
|
||||
],
|
||||
};
|
||||
|
||||
describe('optimisticUpdate', () => {
|
||||
let currentState: TestState;
|
||||
|
||||
beforeEach(() => {
|
||||
currentState = JSON.parse(JSON.stringify(initialState));
|
||||
});
|
||||
|
||||
describe('成功场景', () => {
|
||||
it('应该执行乐观更新并在API成功后保持更新', async () => {
|
||||
const mockApiCall = jest.fn().mockResolvedValue({ success: true, likes: 11 });
|
||||
|
||||
const result = await optimisticUpdate<TestState, { success: boolean; likes: number }>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => ({
|
||||
...state,
|
||||
posts: state.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
||||
),
|
||||
}),
|
||||
apiCall: mockApiCall,
|
||||
onSuccess: (result, current) => ({
|
||||
...current,
|
||||
posts: current.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: result.likes } : p
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
// 验证API被调用
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
||||
// 验证返回结果
|
||||
expect(result).toEqual({ success: true, likes: 11 });
|
||||
});
|
||||
|
||||
it('应该在乐观更新后立即改变状态', async () => {
|
||||
const mockApiCall = jest.fn().mockImplementation(() => {
|
||||
// 在API调用期间验证状态已被乐观更新
|
||||
expect(currentState.posts[0].isLiked).toBe(true);
|
||||
expect(currentState.posts[0].likes).toBe(11);
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
await optimisticUpdate<TestState, any>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => {
|
||||
const newState = {
|
||||
...state,
|
||||
posts: state.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
||||
),
|
||||
};
|
||||
currentState = newState; // 模拟状态更新
|
||||
return newState;
|
||||
},
|
||||
apiCall: mockApiCall,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('失败回滚场景', () => {
|
||||
it('应该在API失败时回滚到原始状态', async () => {
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
||||
const onError = jest.fn((error, original) => original);
|
||||
|
||||
const result = await optimisticUpdate<TestState, any>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => ({
|
||||
...state,
|
||||
posts: state.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
||||
),
|
||||
}),
|
||||
apiCall: mockApiCall,
|
||||
onError,
|
||||
errorMessage: '点赞失败',
|
||||
});
|
||||
|
||||
// 验证API被调用
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
||||
// 验证onError被调用
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
initialState
|
||||
);
|
||||
// 验证返回undefined(因为失败了)
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该在静默模式下不抛出错误', async () => {
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// 不应该抛出错误
|
||||
await expect(
|
||||
optimisticUpdate<TestState, any>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => state,
|
||||
apiCall: mockApiCall,
|
||||
silent: true,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该在非静默模式下抛出错误', async () => {
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// 应该抛出错误
|
||||
await expect(
|
||||
optimisticUpdate<TestState, any>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => state,
|
||||
apiCall: mockApiCall,
|
||||
silent: false,
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('应该记录错误日志', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await optimisticUpdate<TestState, any>({
|
||||
getState: () => currentState,
|
||||
optimisticUpdate: (state) => state,
|
||||
apiCall: mockApiCall,
|
||||
errorMessage: '自定义错误消息',
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('自定义错误消息:', expect.any(Error));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('simpleOptimisticUpdate', () => {
|
||||
let currentState: TestState;
|
||||
let setStateMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
currentState = JSON.parse(JSON.stringify(initialState));
|
||||
setStateMock = jest.fn((newState) => {
|
||||
currentState = newState;
|
||||
});
|
||||
});
|
||||
|
||||
it('应该成功执行乐观更新', async () => {
|
||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await simpleOptimisticUpdate<TestState>({
|
||||
getState: () => currentState,
|
||||
setState: setStateMock,
|
||||
optimisticUpdate: (state) => ({
|
||||
...state,
|
||||
posts: state.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
|
||||
),
|
||||
}),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: mockApiCall,
|
||||
});
|
||||
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
||||
expect(setStateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('应该在失败时回滚状态', async () => {
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
|
||||
const rollbackMock = jest.fn((original) => original);
|
||||
|
||||
await simpleOptimisticUpdate<TestState>({
|
||||
getState: () => currentState,
|
||||
setState: setStateMock,
|
||||
optimisticUpdate: (state) => ({
|
||||
...state,
|
||||
posts: state.posts.map((p) =>
|
||||
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
|
||||
),
|
||||
}),
|
||||
rollbackState: rollbackMock,
|
||||
apiCall: mockApiCall,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
// 验证乐观更新和回滚都被调用
|
||||
expect(setStateMock).toHaveBeenCalledTimes(2);
|
||||
expect(rollbackMock).toHaveBeenCalledWith(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOptimisticUpdaterFactory', () => {
|
||||
interface StoreState {
|
||||
posts: Array<{ id: string; likes: number }>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
let storeState: StoreState;
|
||||
let setMock: jest.Mock;
|
||||
let getMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
storeState = {
|
||||
posts: [{ id: '1', likes: 10 }],
|
||||
count: 0,
|
||||
};
|
||||
setMock = jest.fn((fn) => {
|
||||
storeState = fn(storeState);
|
||||
});
|
||||
getMock = jest.fn(() => storeState);
|
||||
});
|
||||
|
||||
it('应该创建乐观更新器并正常工作', async () => {
|
||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: mockApiCall,
|
||||
});
|
||||
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
||||
expect(setMock).toHaveBeenCalledTimes(1);
|
||||
expect(storeState.posts[0].likes).toBe(11);
|
||||
});
|
||||
|
||||
it('应该在API失败时回滚状态', async () => {
|
||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
|
||||
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: mockApiCall,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
// 乐观更新 + 回滚 = 2次set调用
|
||||
expect(setMock).toHaveBeenCalledTimes(2);
|
||||
// 最终状态应该和初始状态一样
|
||||
expect(storeState.posts[0].likes).toBe(10);
|
||||
});
|
||||
|
||||
it('应该处理不同的state key', async () => {
|
||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await optimisticUpdater({
|
||||
key: 'count',
|
||||
optimisticUpdate: (count) => count + 1,
|
||||
rollbackState: (original) => original,
|
||||
apiCall: mockApiCall,
|
||||
});
|
||||
|
||||
expect(storeState.count).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user