Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
308
src/screens/auth/ForgotPasswordScreen.tsx
Normal file
308
src/screens/auth/ForgotPasswordScreen.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} 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 { RootStackParamList } from '../../navigation/types';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type ForgotPasswordNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const ForgotPasswordScreen: React.FC = () => {
|
||||
const navigation = useNavigation<ForgotPasswordNavigationProp>();
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!validateEmail(email.trim())) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const ok = await authService.sendPasswordResetCode(email.trim());
|
||||
if (ok) {
|
||||
setCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!validateEmail(email.trim())) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!verificationCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showPrompt({ title: '提示', message: '两次输入的密码不一致', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ok = await authService.resetPassword({
|
||||
email: email.trim(),
|
||||
verification_code: verificationCode.trim(),
|
||||
new_password: newPassword,
|
||||
});
|
||||
if (ok) {
|
||||
showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' });
|
||||
navigation.goBack();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.title}>找回密码</Text>
|
||||
<Text style={styles.subtitle}>请输入邮箱并完成验证码验证</Text>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="新密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
|
||||
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认新密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
|
||||
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.backButtonText}>返回登录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
gradient: { flex: 1 },
|
||||
keyboardView: { flex: 1 },
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing['2xl'],
|
||||
},
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
padding: spacing.xl,
|
||||
...shadows.md,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
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: 52,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
codeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
codeInput: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
sendCodeButton: {
|
||||
height: 52,
|
||||
minWidth: 110,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
sendCodeButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
sendCodeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitButton: {
|
||||
height: 50,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
},
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
backButtonText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ForgotPasswordScreen;
|
||||
559
src/screens/auth/LoginScreen.tsx
Normal file
559
src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 登录页 LoginScreen(响应式适配)
|
||||
* 胡萝卜BBS - 用户登录
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 登录表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
} 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 { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
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 { isWideScreen, isLandscape, isDesktop, width } = useResponsive();
|
||||
|
||||
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;
|
||||
|
||||
// 响应式值
|
||||
const logoSize = useResponsiveValue({ xs: 64, sm: 72, md: 80, lg: 96, xl: 110 });
|
||||
const logoContainerSize = useResponsiveValue({ xs: 110, sm: 120, md: 130, lg: 150, xl: 170 });
|
||||
const appNameSize = useResponsiveValue({ xs: 28, sm: 30, md: 32, lg: 36, xl: 40 });
|
||||
const formMaxWidth = isDesktop ? 480 : isWideScreen ? 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) {
|
||||
// authStore 已将错误写入 storeError,useEffect 会同步到 errorMsg
|
||||
// 若 storeError 为空则显示通用提示
|
||||
if (!useAuthStore.getState().error) {
|
||||
setErrorMsg('登录失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到注册页
|
||||
const handleGoToRegister = () => {
|
||||
navigation.navigate('Register' as any);
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* Logo和标题区域 */}
|
||||
<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>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.formCard,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.formTitle}>欢迎回来</Text>
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
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,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
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?.main ?? '#D32F2F'}
|
||||
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={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.loginButtonGradient,
|
||||
isWideScreen && styles.loginButtonGradientWide,
|
||||
]}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing['2xl'],
|
||||
},
|
||||
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,
|
||||
},
|
||||
appName: {
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: spacing.sm,
|
||||
textShadowColor: 'rgba(0,0,0,0.1)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
// 表单卡片
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
padding: spacing['2xl'],
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.md,
|
||||
},
|
||||
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,
|
||||
},
|
||||
inputWrapperWide: {
|
||||
height: 60,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
inputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: '#FFEBEE',
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#D32F2F',
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#D32F2F',
|
||||
lineHeight: 20,
|
||||
},
|
||||
loginButton: {
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
loginButtonGradient: {
|
||||
height: 52,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loginButtonGradientWide: {
|
||||
height: 56,
|
||||
},
|
||||
loginButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loginButtonText: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
// 底部区域
|
||||
footerSection: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
registerLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
843
src/screens/auth/RegisterScreen.tsx
Normal file
843
src/screens/auth/RegisterScreen.tsx
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 注册页 RegisterScreen(响应式适配)
|
||||
* 胡萝卜BBS - 用户注册
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 注册表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
} 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 { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const RegisterScreen: React.FC = () => {
|
||||
const navigation = useNavigation<RegisterNavigationProp>();
|
||||
const register = useAuthStore((state) => state.register);
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isLandscape, isDesktop } = useResponsive();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(true);
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.95)).current;
|
||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式值
|
||||
const iconSize = useResponsiveValue({ xs: 48, sm: 52, md: 56, lg: 64, xl: 72 });
|
||||
const iconContainerSize = useResponsiveValue({ xs: 80, sm: 88, md: 96, lg: 108, xl: 120 });
|
||||
const titleSize = useResponsiveValue({ xs: 24, sm: 26, md: 28, lg: 30, xl: 32 });
|
||||
const formMaxWidth = isDesktop ? 520 : isWideScreen ? 480 : undefined;
|
||||
|
||||
// 启动入场动画
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(inputFadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
if (!username.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入用户名', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showPrompt({ title: '提示', message: '用户名长度需在3-20个字符之间', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
showPrompt({ title: '提示', message: '用户名只能包含字母、数字和下划线', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!nickname.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入昵称', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (nickname.length < 2 || nickname.length > 20) {
|
||||
showPrompt({ title: '提示', message: '昵称长度需在2-20个字符之间', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入邮箱', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!verificationCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入邮箱验证码', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
// 手机号验证(如果填写了)
|
||||
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
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;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showPrompt({ title: '提示', message: '两次输入的密码不一致', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!agreedToTerms) {
|
||||
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!email.trim()) {
|
||||
showPrompt({ title: '提示', message: '请先输入邮箱', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const ok = await authService.sendRegisterCode(email.trim());
|
||||
if (ok) {
|
||||
setCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败,请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await register({
|
||||
username,
|
||||
password,
|
||||
nickname,
|
||||
email: email.trim(),
|
||||
verification_code: verificationCode.trim(),
|
||||
phone: phone || undefined,
|
||||
});
|
||||
if (success) {
|
||||
// 注册成功,导航会自动切换到主页
|
||||
} else {
|
||||
const msg = useAuthStore.getState().error || '注册失败,请稍后重试';
|
||||
showPrompt({ title: '注册失败', message: msg, type: 'error' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '注册失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到登录页
|
||||
const handleGoToLogin = () => {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 标题区域 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ width: iconContainerSize, height: iconContainerSize, borderRadius: iconContainerSize / 2 }
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-plus"
|
||||
size={iconSize}
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.formCard,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="用户名(3-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={20}
|
||||
/>
|
||||
{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,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="emoticon-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="昵称(2-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={nickname}
|
||||
onChangeText={setNickname}
|
||||
maxLength={20}
|
||||
/>
|
||||
{nickname.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setNickname('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 邮箱输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="email-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="邮箱(必填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setEmail('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 邮箱验证码 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInputWrapper, isWideScreen && styles.inputWrapperWide]}>
|
||||
<MaterialCommunityIcons
|
||||
name="shield-key-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, isWideScreen && styles.inputWide]}
|
||||
placeholder="邮箱验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sendCodeButton,
|
||||
(sendingCode || countdown > 0) && styles.sendCodeButtonDisabled,
|
||||
]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.sendCodeButtonText}>
|
||||
{sendingCode ? '发送中...' : countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 手机号输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="phone-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="手机号(选填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
maxLength={11}
|
||||
/>
|
||||
{phone.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setPhone('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-check-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="确认密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleRegister}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 服务条款 */}
|
||||
<TouchableOpacity
|
||||
style={styles.termsContainer}
|
||||
onPress={() => setAgreedToTerms(!agreedToTerms)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={[styles.checkbox, agreedToTerms && styles.checkboxChecked]}>
|
||||
<MaterialCommunityIcons
|
||||
name={agreedToTerms ? "checkbox-marked" : "checkbox-blank-outline"}
|
||||
size={24}
|
||||
color={agreedToTerms ? colors.primary.main : colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.termsText}>
|
||||
我已阅读并同意
|
||||
<Text style={styles.termsLink}>《用户协议》</Text>
|
||||
和
|
||||
<Text style={styles.termsLink}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.registerButton, loading && styles.registerButtonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.registerButtonGradient,
|
||||
isWideScreen && styles.registerButtonGradientWide,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.registerButtonText}>注 册</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* 底部登录提示 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footerSection,
|
||||
{ opacity: inputFadeAnim },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.footerText}>已有账号?</Text>
|
||||
<TouchableOpacity onPress={handleGoToLogin}>
|
||||
<Text style={styles.loginLink}>立即登录</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
scrollContentLandscape: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
// 装饰性背景元素
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -80,
|
||||
right: -80,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: 150,
|
||||
left: -120,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
// 头部区域
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
headerSectionLandscape: {
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.4)',
|
||||
...shadows.md,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: spacing.xs,
|
||||
textShadowColor: 'rgba(0,0,0,0.1)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
// 表单卡片
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
padding: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
...shadows.md,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
codeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 52,
|
||||
},
|
||||
inputWrapperWide: {
|
||||
height: 56,
|
||||
},
|
||||
codeInputWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
inputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
sendCodeButton: {
|
||||
height: 52,
|
||||
minWidth: 110,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendCodeButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
sendCodeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// 服务条款
|
||||
termsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
checkbox: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
checkboxChecked: {
|
||||
// 选中状态的额外样式
|
||||
},
|
||||
termsText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
termsLink: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 注册按钮
|
||||
registerButton: {
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
registerButtonGradient: {
|
||||
height: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
registerButtonGradientWide: {
|
||||
height: 54,
|
||||
},
|
||||
registerButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
registerButtonText: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
// 底部区域
|
||||
footerSection: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
loginLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
8
src/screens/auth/index.ts
Normal file
8
src/screens/auth/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 认证屏幕导出
|
||||
* 胡萝卜BBS - 登录注册模块
|
||||
*/
|
||||
|
||||
export { LoginScreen } from './LoginScreen';
|
||||
export { RegisterScreen } from './RegisterScreen';
|
||||
export { ForgotPasswordScreen } from './ForgotPasswordScreen';
|
||||
Reference in New Issue
Block a user