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:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
export const AccountSecurityScreen: React.FC = () => {
const { isWideScreen } = useResponsive();
const currentUser = useAuthStore((state) => state.currentUser);
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser);
const [email, setEmail] = useState(currentUser?.email || '');
const [verificationCode, setVerificationCode] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [verifyingEmail, setVerifyingEmail] = useState(false);
const [countdown, setCountdown] = useState(0);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changePasswordCode, setChangePasswordCode] = useState('');
const [sendingChangePwdCode, setSendingChangePwdCode] = useState(false);
const [changePwdCountdown, setChangePwdCountdown] = useState(0);
const [updatingPassword, setUpdatingPassword] = useState(false);
useEffect(() => {
if (currentUser?.email) {
setEmail(currentUser.email);
}
}, [currentUser?.email]);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [countdown]);
useEffect(() => {
if (changePwdCountdown <= 0) {
return;
}
const timer = setInterval(() => {
setChangePwdCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [changePwdCountdown]);
const isEmailVerified = !!currentUser?.email_verified;
const emailStatusText = useMemo(() => {
if (!currentUser?.email) {
return '未绑定邮箱';
}
return isEmailVerified ? '已验证' : '未验证';
}, [currentUser?.email, isEmailVerified]);
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const handleSendCode = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (countdown > 0) {
return;
}
setSendingCode(true);
try {
const ok = await authService.sendCurrentUserEmailVerifyCode(targetEmail);
if (ok) {
setCountdown(60);
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingCode(false);
}
};
const handleVerifyEmail = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (!verificationCode.trim()) {
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
return;
}
setVerifyingEmail(true);
try {
const ok = await authService.verifyCurrentUserEmail({
email: targetEmail,
verification_code: verificationCode.trim(),
});
if (ok) {
setVerificationCode('');
await fetchCurrentUser();
showPrompt({ title: '验证成功', message: '邮箱已完成验证', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '验证失败', message: resolveAuthApiError(error, '邮箱验证失败'), type: 'error' });
} finally {
setVerifyingEmail(false);
}
};
const handleChangePassword = async () => {
if (!oldPassword) {
showPrompt({ title: '提示', message: '请输入当前密码', type: 'warning' });
return;
}
if (!changePasswordCode.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;
}
setUpdatingPassword(true);
try {
const ok = await authService.changePassword(oldPassword, newPassword, changePasswordCode.trim());
if (ok) {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setChangePasswordCode('');
showPrompt({ title: '修改成功', message: '密码已更新', type: 'success' });
} else {
showPrompt({ title: '修改失败', message: '请检查当前密码后重试', type: 'error' });
}
} catch (error: any) {
showPrompt({ title: '修改失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
} finally {
setUpdatingPassword(false);
}
};
const handleSendChangePasswordCode = async () => {
if (changePwdCountdown > 0) {
return;
}
setSendingChangePwdCode(true);
try {
const ok = await authService.sendChangePasswordCode();
if (ok) {
setChangePwdCountdown(60);
showPrompt({ title: '发送成功', message: '改密验证码已发送到你的邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingChangePwdCode(false);
}
};
const content = (
<>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="email-check-outline" size={18} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.statusRow}>
<Text variant="body" color={colors.text.primary}></Text>
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
<Text variant="caption" color={isEmailVerified ? '#1B5E20' : '#B26A00'}>
{emailStatusText}
</Text>
</View>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={20} 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={20} 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.buttonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
onPress={handleVerifyEmail}
disabled={verifyingEmail}
>
{verifyingEmail ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>}
</TouchableOpacity>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="lock-reset" size={18} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="当前密码"
placeholderTextColor={colors.text.hint}
value={oldPassword}
onChangeText={setOldPassword}
secureTextEntry
/>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="新密码至少6位"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
</View>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint}
value={changePasswordCode}
onChangeText={setChangePasswordCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
onPress={handleSendChangePasswordCode}
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
onPress={handleChangePassword}
disabled={updatingPassword}
>
{updatingPassword ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>}
</TouchableOpacity>
</View>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
sectionTitle: {
fontWeight: '600',
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
padding: spacing.md,
},
statusRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
statusBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
},
statusVerified: {
backgroundColor: '#E8F5E9',
},
statusUnverified: {
backgroundColor: '#FFF3E0',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 48,
marginBottom: spacing.sm,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 48,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
primaryButton: {
height: 48,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.xs,
},
primaryButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '700',
},
buttonDisabled: {
opacity: 0.6,
},
});
export default AccountSecurityScreen;