Files
frontend/src/screens/auth/LoginScreen.tsx
lafay a005fb0a15 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
2026-03-20 19:28:42 +08:00

760 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 登录页 LoginScreen响应式适配 - 分栏布局)
* 胡萝卜BBS - 用户登录
*
* 布局规则:
* - 屏幕宽度 < 768px单栏布局橙色渐变背景
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
KeyboardAvoidingView,
Platform,
ScrollView,
ActivityIndicator,
Animated,
StatusBar,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
import { useAuthStore } from '../../stores';
import { RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveValue } from '../../hooks';
import { showPrompt } from '../../services/promptService';
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
// 分栏布局断点
const SPLIT_BREAKPOINT = 768;
export const LoginScreen: React.FC = () => {
const navigation = useNavigation<LoginNavigationProp>();
const login = useAuthStore((state) => state.login);
const storeError = useAuthStore((state) => state.error);
const setStoreError = useAuthStore((state) => state.setError);
// 响应式布局
const { isLandscape, width } = useResponsive();
// 是否使用分栏布局
const isSplitLayout = width >= SPLIT_BREAKPOINT;
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
const inputFadeAnim = useRef(new Animated.Value(0)).current;
// 响应式值 - 大屏模式单独放大Logo尺寸更显大气
const logoSize = useResponsiveValue({
xs: 64, sm: 72, md: 80,
lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
xl: isSplitLayout ? 160 : 110
});
const logoContainerSize = useResponsiveValue({
xs: 110, sm: 120, md: 130,
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
xl: isSplitLayout ? 220 : 170
});
const appNameSize = useResponsiveValue({
xs: 28, sm: 30, md: 32,
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
xl: isSplitLayout ? 72 : 40
});
const formMaxWidth = isSplitLayout ? 420 : undefined;
// 启动入场动画
useEffect(() => {
Animated.sequence([
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]),
Animated.timing(slideAnim, {
toValue: 0,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(inputFadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 表单验证
const validateForm = (): boolean => {
if (!username.trim()) {
showPrompt({ title: '提示', message: '请输入用户名', type: 'warning' });
return false;
}
if (!password) {
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
return false;
}
if (password.length < 6) {
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
return false;
}
return true;
};
// store error 同步到本地
useEffect(() => {
if (storeError) {
setErrorMsg(storeError);
}
}, [storeError]);
const clearError = () => {
setErrorMsg(null);
setStoreError(null);
};
// 处理登录
const handleLogin = async () => {
if (!validateForm()) return;
clearError();
setLoading(true);
try {
const success = await login({ username, password });
if (!success) {
if (!useAuthStore.getState().error) {
setErrorMsg('登录失败,请稍后重试');
}
}
} catch (error: any) {
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
} finally {
setLoading(false);
}
};
// 跳转到注册页
const handleGoToRegister = () => {
navigation.navigate('Register' as any);
};
// 渲染左侧面板Logo和品牌信息- 优化大屏布局
const renderLeftPanel = () => (
<Animated.View
style={[
styles.leftPanel,
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
{/* 胡萝卜图标 - 靠上显示 */}
<View style={[
styles.logoContainer,
styles.logoContainerTop,
isSplitLayout && styles.splitLogoContainer, // 大屏模式Logo容器样式
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
{/* 品牌名称 - 优化大屏排版 */}
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
{isSplitLayout ? (
// 大屏模式:水平排列 + 大幅放大图标
<View style={styles.splitDecorRow}>
<MaterialCommunityIcons name="chat-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="account-group-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="bell-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
</View>
) : (
// 小屏模式:保持原有横向排列
<View style={styles.decorRow}>
<MaterialCommunityIcons name="chat-outline" size={48} color="rgba(255,255,255,0.35)" />
<MaterialCommunityIcons name="account-group-outline" size={44} color="rgba(255,255,255,0.3)" />
<MaterialCommunityIcons name="bell-outline" size={40} color="rgba(255,255,255,0.25)" />
</View>
)}
</View>
</Animated.View>
);
// 渲染表单内容
const renderFormContent = () => (
<View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
<Animated.View
style={[
styles.formCard,
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
{
opacity: inputFadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
<Text style={styles.formTitle}></Text>
{/* 用户名输入框 */}
<View style={styles.inputContainer}>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="account-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="用户名 / 邮箱 / 手机号"
placeholderTextColor={colors.text.hint}
value={username}
onChangeText={(v) => { setUsername(v); clearError(); }}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
{username.length > 0 && (
<TouchableOpacity
onPress={() => setUsername('')}
style={styles.clearButton}
>
<MaterialCommunityIcons
name="close-circle"
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
)}
</View>
</View>
{/* 密码输入框 */}
<View style={styles.inputContainer}>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="请输入密码"
placeholderTextColor={colors.text.hint}
value={password}
onChangeText={(v) => { setPassword(v); clearError(); }}
secureTextEntry={!showPassword}
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={handleLogin}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.clearButton}
>
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
</View>
</View>
{/* 忘记密码 */}
<TouchableOpacity style={styles.forgotPassword} onPress={() => navigation.navigate('ForgotPassword' as any)}>
<Text style={styles.forgotPasswordText}></Text>
</TouchableOpacity>
{/* 内联错误提示 */}
{errorMsg && (
<View style={styles.errorBox}>
<MaterialCommunityIcons
name="alert-circle-outline"
size={16}
color={colors.error.dark}
style={{ marginRight: 6, marginTop: 1 }}
/>
<Text style={styles.errorText}>{errorMsg}</Text>
</View>
)}
{/* 登录按钮 */}
<TouchableOpacity
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
onPress={handleLogin}
disabled={loading}
activeOpacity={0.8}
>
<LinearGradient
colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.loginButtonGradient}
>
{loading ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.loginButtonText}> </Text>
)}
</LinearGradient>
</TouchableOpacity>
</Animated.View>
{/* 底部注册提示 */}
<Animated.View
style={[
styles.footerSection,
{ opacity: inputFadeAnim },
]}
>
<Text style={styles.footerText}></Text>
<TouchableOpacity onPress={handleGoToRegister}>
<Text style={styles.registerLink}></Text>
</TouchableOpacity>
</Animated.View>
</View>
);
// 渲染分栏布局
const renderSplitLayout = () => (
<View style={styles.splitContainer}>
{/* 左侧橙色面板 */}
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.leftPanelContainer}
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
<StatusBar barStyle="light-content" />
<SafeAreaView style={styles.leftPanelSafeArea}>
{renderLeftPanel()}
</SafeAreaView>
</LinearGradient>
{/* 右侧中性灰面板 */}
<View style={styles.rightPanelContainer}>
<StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.rightPanelSafeArea}>
{/* 核心修复用View包裹确保垂直+水平居中 */}
<View style={styles.splitRightContentWrapper}>
{renderFormContent()}
</View>
</SafeAreaView>
</View>
</View>
);
// 渲染单栏布局
const renderSingleLayout = () => (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
isLandscape && styles.scrollContentLandscape,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
{/* 头部 */}
<Animated.View
style={[
styles.headerSection,
isLandscape && styles.headerSectionLandscape,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<View style={[
styles.logoContainer,
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView>
);
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
keyboardView: {
flex: 1,
},
// 单栏布局内容
scrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
scrollContentCentered: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
scrollContentLandscape: {
paddingVertical: spacing.lg,
},
// 装饰性背景元素
decorCircle1: {
position: 'absolute',
top: -100,
right: -100,
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(255,255,255,0.1)',
},
decorCircle2: {
position: 'absolute',
bottom: 100,
left: -150,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: 'rgba(255,255,255,0.08)',
},
// 头部区域
headerSection: {
alignItems: 'center',
marginTop: spacing['3xl'],
marginBottom: spacing['3xl'],
},
headerSectionLandscape: {
marginTop: spacing.lg,
marginBottom: spacing.lg,
},
logoContainer: {
backgroundColor: 'rgba(255,255,255,0.25)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.4)',
...shadows.md,
},
// 大屏模式Logo容器样式 - 增加阴影和内边距,更立体
splitLogoContainer: {
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.5)',
...shadows.lg,
},
logoContainerTop: {
marginTop: spacing['3xl'],
},
appName: {
fontWeight: '800',
color: '#FFFFFF',
marginBottom: spacing.sm,
textShadowColor: 'rgba(0,0,0,0.1)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
splitAppName: {
fontSize: 72, // 大幅放大主标题
marginBottom: spacing['2xl'],
textShadowOffset: { width: 0, height: 4 },
textShadowRadius: 8,
letterSpacing: 4, // 增加字间距,更显大气
fontWeight: '900',
},
subtitle: {
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)',
textAlign: 'center',
paddingHorizontal: spacing.xl,
},
// 大屏模式副标题样式 - 大幅放大
splitSubtitle: {
fontSize: 24, // 放大副标题
marginBottom: spacing['6xl'], // 增加底部间距
lineHeight: 36,
paddingHorizontal: spacing['4xl'],
letterSpacing: 1.5,
fontWeight: '500',
},
// 表单卡片(基础样式,无阴影)
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing['2xl'],
marginBottom: spacing.xl,
},
// 小屏模式:原有默认阴影
normalFormCardShadow: {
...shadows.md,
},
// 大屏模式:专用全向阴影
splitFormCardShadow: {
// iOS 全向柔和阴影
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 12,
// Android 高程 + 轻微边框(模拟顶部阴影)
elevation: 8,
// 新增:极细微的顶部边框,强化区分度
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
},
formTitle: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
marginBottom: spacing.xl,
textAlign: 'center',
},
inputContainer: {
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 56,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
},
clearButton: {
padding: spacing.xs,
},
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: spacing.md,
},
forgotPasswordText: {
fontSize: fontSizes.sm,
color: colors.primary.main,
fontWeight: '600',
},
errorBox: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.accent.light,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginBottom: spacing.md,
borderLeftWidth: 3,
borderLeftColor: colors.accent.main,
},
errorText: {
flex: 1,
fontSize: fontSizes.sm,
color: colors.accent.dark,
lineHeight: 20,
},
loginButton: {
borderRadius: borderRadius.lg,
overflow: 'hidden',
...shadows.md,
},
loginButtonGradient: {
height: 52,
alignItems: 'center',
justifyContent: 'center',
},
loginButtonDisabled: {
opacity: 0.7,
},
loginButtonText: {
fontSize: fontSizes.lg,
fontWeight: '700',
color: '#FFFFFF',
},
// 底部区域
footerSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingTop: spacing.xl,
},
footerText: {
fontSize: fontSizes.md,
color: colors.text.primary,
},
registerLink: {
fontSize: fontSizes.md,
color: colors.primary.main,
fontWeight: '700',
marginLeft: spacing.xs,
textDecorationLine: 'underline',
},
// ========== 分栏布局样式 ==========
splitContainer: {
flex: 1,
flexDirection: 'row',
},
leftPanelContainer: {
flex: 1, // 50%
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
leftPanelSafeArea: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
},
leftPanel: {
alignItems: 'center',
paddingHorizontal: spacing['2xl'],
},
// 大屏模式左侧面板布局
splitLeftPanel: {
width: '100%',
justifyContent: 'center',
paddingVertical: spacing['6xl'],
},
leftPanelDecor: {
position: 'absolute',
bottom: spacing['4xl'],
left: 0,
right: 0,
alignItems: 'center',
},
leftPanelDecorBottom: {
marginTop: spacing['2xl'],
},
// 大屏模式装饰元素布局
splitLeftPanelDecorBottom: {
marginTop: spacing['6xl'],
width: '100%',
alignItems: 'center',
},
// 大屏模式装饰元素水平排列
splitDecorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '100%',
gap: spacing['2xl'], // 增加图标间距
},
// 大屏模式装饰图标样式
splitDecorIcon: {
transform: [{ scale: 1.1 }],
opacity: 0.9,
},
decorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '80%',
},
rightPanelContainer: {
flex: 1, // 50%
backgroundColor: colors.background.paper,
},
rightPanelSafeArea: {
flex: 1,
// 移除左右内边距,交给子容器控制
},
// 分栏布局右侧内容包裹器(核心修复)
splitRightContentWrapper: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
// 新增:左右内边距,保证表单和边缘有间距
paddingHorizontal: spacing['2xl'],
},
// 表单内容包裹器(区分大屏/小屏)
splitFormWrapper: {
// 关键修改:设置最大宽度 + 自适应宽度,实现水平居中
maxWidth: 420,
width: '100%',
},
singleFormWrapper: {
flexGrow: 1,
width: '100%',
// 移除这里的阴影,避免重复
},
});
export default LoginScreen;