Files
frontend/src/screens/auth/LoginScreen.tsx

760 lines
22 KiB
TypeScript
Raw Normal View History

/**
2026-03-16 17:47:10 +08:00
* LoginScreen -
* BBS -
2026-03-16 17:47:10 +08:00
*
*
* - < 768px
* - >= 768px
*/
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
KeyboardAvoidingView,
Platform,
ScrollView,
ActivityIndicator,
Animated,
2026-03-16 17:47:10 +08:00
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'>;
2026-03-16 17:47:10 +08:00
// 分栏布局断点
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);
// 响应式布局
2026-03-16 17:47:10 +08:00
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;
2026-03-16 17:47:10 +08:00
// 响应式值 - 大屏模式单独放大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;
};
2026-03-16 17:47:10 +08:00
// 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);
};
2026-03-16 17:47:10 +08:00
// 渲染左侧面板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>
2026-03-16 17:47:10 +08:00
<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 = () => (
2026-03-16 17:47:10 +08:00
<View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
<Animated.View
style={[
styles.formCard,
2026-03-16 17:47:10 +08:00
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
{
opacity: inputFadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
<Text style={styles.formTitle}></Text>
{/* 用户名输入框 */}
<View style={styles.inputContainer}>
2026-03-16 17:47:10 +08:00
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="account-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
2026-03-16 17:47:10 +08:00
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}>
2026-03-16 17:47:10 +08:00
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
2026-03-16 17:47:10 +08:00
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}
2026-03-16 17:47:10 +08:00
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
2026-03-16 17:47:10 +08:00
colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
2026-03-16 17:47:10 +08:00
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>
2026-03-16 17:47:10 +08:00
</View>
);
2026-03-16 17:47:10 +08:00
// 渲染分栏布局
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}>
2026-03-16 17:47:10 +08:00
<StatusBar barStyle="light-content" />
<LinearGradient
2026-03-16 17:47:10 +08:00
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}
>
2026-03-16 17:47:10 +08:00
<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 }],
},
]}
>
2026-03-16 17:47:10 +08:00
<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>
2026-03-16 17:47:10 +08:00
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView>
);
2026-03-16 17:47:10 +08:00
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
keyboardView: {
flex: 1,
},
2026-03-16 17:47:10 +08:00
// 单栏布局内容
scrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
2026-03-16 17:47:10 +08:00
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,
},
2026-03-16 17:47:10 +08:00
// 大屏模式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,
},
2026-03-16 17:47:10 +08:00
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
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,
},
2026-03-16 17:47:10 +08:00
// 大屏模式副标题样式 - 大幅放大
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,
2026-03-16 17:47:10 +08:00
},
// 小屏模式:原有默认阴影
normalFormCardShadow: {
...shadows.md,
},
2026-03-16 17:47:10 +08:00
// 大屏模式:专用全向阴影
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,
2026-03-16 17:47:10 +08:00
fontWeight: '600',
},
errorBox: {
flexDirection: 'row',
alignItems: 'flex-start',
2026-03-16 17:47:10 +08:00
backgroundColor: colors.accent.light,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginBottom: spacing.md,
borderLeftWidth: 3,
2026-03-16 17:47:10 +08:00
borderLeftColor: colors.accent.main,
},
errorText: {
flex: 1,
fontSize: fontSizes.sm,
2026-03-16 17:47:10 +08:00
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,
2026-03-16 17:47:10 +08:00
color: colors.text.primary,
},
registerLink: {
fontSize: fontSizes.md,
2026-03-16 17:47:10 +08:00
color: colors.primary.main,
fontWeight: '700',
marginLeft: spacing.xs,
textDecorationLine: 'underline',
},
2026-03-16 17:47:10 +08:00
// ========== 分栏布局样式 ==========
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%',
// 移除这里的阴影,避免重复
},
});
2026-03-16 17:47:10 +08:00
export default LoginScreen;