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

305 lines
10 KiB
TypeScript
Raw Normal View History

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 { useRouter } from 'expo-router';
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 { showPrompt } from '../../services/promptService';
export const ForgotPasswordScreen: React.FC = () => {
const router = useRouter();
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' });
router.back();
}
} 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={() => router.back()}>
<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;