feat: add QR code login and enhance chat experience

- Add QR code login flow with scanner and confirmation screens
- Implement swipe-to-reply in chat with SwipeableMessageBubble
- Replace FlatList with FlashList for chat performance optimization
- Add Schedule stack navigator with CourseDetail screen support
- Configure deep linking for QR code login (carrotbbs://qrcode/login/:sessionId)
- Update branding from 胡萝卜 to 萝卜社区
- Display dynamic app version in settings
This commit is contained in:
lafay
2026-03-20 19:28:42 +08:00
parent 59877e6ae3
commit a005fb0a15
24 changed files with 2878 additions and 1859 deletions

View File

@@ -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>

View 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;

View File

@@ -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>
{/* 表单 */}