2026-03-09 21:29:03 +08:00
|
|
|
|
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';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { useRouter } from 'expo-router';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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 = () => {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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' });
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.back();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} 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>
|
|
|
|
|
|
|
2026-03-24 14:21:31 +08:00
|
|
|
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<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;
|