Files
frontend/src/screens/profile/EditProfileScreen.tsx
lafay a8c78f0c3f
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m35s
Frontend CI / ota-android (push) Successful in 13m3s
Frontend CI / build-android-apk (push) Successful in 39m23s
feat: 同步dev分支并保留当前修改
2026-03-21 03:22:28 +08:00

793 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 编辑资料页 EditProfileScreen响应式适配
* 胡萝卜BBS - 编辑用户资料
* 与用户资料页样式完全一致
* 表单在宽屏下居中显示
*/
import React, { useState } from 'react';
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
Image,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
import { useAuthStore } from '../../stores';
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
import { authService, uploadService } from '../../services';
import { useResponsive } from '../../hooks';
// 表单输入项组件
interface FormFieldProps {
icon: string;
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder: string;
maxLength?: number;
multiline?: boolean;
numberOfLines?: number;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url';
editable?: boolean;
}
const FormField: React.FC<FormFieldProps> = ({
icon,
label,
value,
onChangeText,
placeholder,
maxLength,
multiline = false,
numberOfLines = 1,
autoCapitalize = 'sentences',
keyboardType = 'default',
editable = true,
}) => {
return (
<View style={styles.formField}>
<View style={styles.fieldIconContainer}>
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} />
</View>
<View style={styles.fieldContent}>
<Text variant="caption" style={styles.fieldLabel}>{label}</Text>
<TextInput
style={[
styles.fieldInput,
multiline && styles.textArea,
!editable && styles.disabledInput
]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline={multiline}
numberOfLines={numberOfLines}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
editable={editable}
/>
</View>
</View>
);
};
export const EditProfileScreen: React.FC = () => {
const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive();
const insets = useSafeAreaInsets();
const [nickname, setNickname] = useState(currentUser?.nickname || '');
const [bio, setBio] = useState(currentUser?.bio || '');
const [location, setLocation] = useState(currentUser?.location || '');
const [website, setWebsite] = useState(currentUser?.website || '');
const [phone, setPhone] = useState(currentUser?.phone || '');
const [email, setEmail] = useState(currentUser?.email || '');
const [avatar, setAvatar] = useState(currentUser?.avatar || '');
const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || '');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [saving, setSaving] = useState(false);
// 底部间距,避免被 TabBar 遮挡
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg;
// 根据屏幕宽度计算封面高度
const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16;
// 选择头图
const handlePickCover = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头图');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [16, 9],
quality: 0.9,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setCoverUrl(selectedImage.uri);
try {
setUploadingCover(true);
const uploadResult = await uploadService.uploadCover({
uri: selectedImage.uri,
name: selectedImage.fileName || 'cover.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ cover_url: uploadResult.url });
Alert.alert('成功', '头图已更新');
} else {
Alert.alert('错误', '头图上传失败,请重试');
}
} catch (error) {
console.error('上传头图失败:', error);
Alert.alert('错误', '头图上传失败,请重试');
} finally {
setUploadingCover(false);
}
}
};
// 选择头像
const handlePickAvatar = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头像');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setAvatar(selectedImage.uri);
try {
setUploadingAvatar(true);
const uploadResult = await uploadService.uploadAvatar({
uri: selectedImage.uri,
name: selectedImage.fileName || 'avatar.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ avatar: uploadResult.url });
Alert.alert('成功', '头像已更新');
} else {
Alert.alert('错误', '头像上传失败,请重试');
}
} catch (error) {
console.error('上传头像失败:', error);
Alert.alert('错误', '头像上传失败,请重试');
} finally {
setUploadingAvatar(false);
}
}
};
// 保存资料
const handleSave = async () => {
if (!nickname.trim()) {
Alert.alert('错误', '昵称不能为空');
return;
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
Alert.alert('错误', '请输入正确的邮箱地址');
return;
}
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
Alert.alert('错误', '请输入正确的手机号');
return;
}
setSaving(true);
try {
const updatedUser = await authService.updateUser({
nickname: nickname.trim(),
bio: bio.trim() || undefined,
location: location.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
});
if (updatedUser) {
updateUser({
nickname: nickname.trim(),
bio: bio.trim() || undefined,
location: location.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
});
Alert.alert('成功', '资料已更新', [
{ text: '确定', onPress: () => navigation.goBack() }
]);
} else {
Alert.alert('错误', '保存失败,请重试');
}
} catch (error) {
console.error('保存资料失败:', error);
Alert.alert('错误', '保存失败,请重试');
} finally {
setSaving(false);
}
};
// 渲染表单内容
const renderFormContent = () => (
<>
{/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */}
<View style={styles.previewContainer}>
{/* 封面背景 */}
<View style={[styles.coverContainer, { height: coverHeight }]}>
<TouchableOpacity
style={styles.coverTouchable}
onPress={handlePickCover}
activeOpacity={0.8}
>
{coverUrl ? (
<Image
source={{ uri: coverUrl }}
style={styles.coverImage}
resizeMode="cover"
/>
) : (
<LinearGradient
colors={['#FF8F66', '#FF6B35', '#E5521D']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
/>
)}
{/* 头图编辑蒙版 */}
{uploadingCover ? (
<View style={styles.coverUploadingOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
</View>
) : (
<View style={styles.coverEditOverlay}>
<MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} />
<Text style={styles.coverEditText}></Text>
</View>
)}
</TouchableOpacity>
{/* 装饰性波浪 */}
<View style={styles.waveDecoration}>
<View style={styles.wave} />
</View>
</View>
{/* 用户信息卡片 */}
<View style={styles.profileCard}>
{/* 悬浮头像 */}
<View style={styles.avatarWrapper}>
<TouchableOpacity
style={styles.avatarContainer}
onPress={handlePickAvatar}
activeOpacity={0.8}
>
<Avatar
source={avatar || null}
size={isWideScreen ? 110 : 90}
name={nickname}
/>
{uploadingAvatar && (
<View style={styles.avatarUploadingOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
</View>
)}
<View style={styles.editAvatarButton}>
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
</View>
</TouchableOpacity>
</View>
{/* 用户名和简介 */}
<View style={styles.userInfo}>
<Text variant="h2" style={styles.nickname}>
{nickname || currentUser?.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
@{currentUser?.username}
</Text>
{bio ? (
<Text variant="body" color={colors.text.secondary} style={styles.bio}>
{bio}
</Text>
) : (
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
~
</Text>
)}
</View>
{/* 个人信息标签 */}
<View style={styles.metaInfo}>
{location ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
{location}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
{website ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
{website.replace(/^https?:\/\//, '')}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
<View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
{new Date(currentUser?.created_at || Date.now()).getFullYear()}
</Text>
</View>
</View>
{/* 编辑提示 */}
<View style={styles.editHint}>
<MaterialCommunityIcons name="information-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.editHintText}>
</Text>
</View>
</View>
</View>
{/* ===== 表单区域 ===== */}
<View style={styles.formCard}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField
icon="account-outline"
label="昵称"
value={nickname}
onChangeText={setNickname}
placeholder="请输入昵称"
maxLength={20}
/>
<View style={styles.divider} />
<FormField
icon="information-outline"
label="个人简介"
value={bio}
onChangeText={setBio}
placeholder="介绍一下自己,让大家更了解你"
maxLength={100}
multiline
numberOfLines={3}
/>
<View style={styles.divider} />
<FormField
icon="map-marker-outline"
label="地区"
value={location}
onChangeText={setLocation}
placeholder="你所在的城市"
maxLength={30}
/>
<View style={styles.divider} />
<FormField
icon="link-variant"
label="个人网站"
value={website}
onChangeText={setWebsite}
placeholder="https://example.com"
maxLength={100}
autoCapitalize="none"
keyboardType="url"
/>
</View>
{/* 联系方式区域 */}
<View style={styles.formCard}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField
icon="phone-outline"
label="手机号"
value={phone}
onChangeText={setPhone}
placeholder="请输入手机号"
maxLength={11}
keyboardType="phone-pad"
/>
<View style={styles.divider} />
<FormField
icon="email-outline"
label="邮箱"
value={email}
onChangeText={setEmail}
placeholder="请输入邮箱地址"
maxLength={100}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
{/* 保存按钮 */}
<View style={[styles.buttonContainer, { marginBottom: scrollBottomInset }]}>
<TouchableOpacity
style={[
styles.saveButton,
(saving || uploadingAvatar || uploadingCover) && styles.saveButtonDisabled
]}
onPress={handleSave}
disabled={saving || uploadingAvatar || uploadingCover}
activeOpacity={0.8}
>
<View style={styles.saveButtonContent}>
{saving ? (
<MaterialCommunityIcons
name="loading"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
) : (
<MaterialCommunityIcons
name="check"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
)}
<Text variant="body" style={[styles.saveButtonText, { fontWeight: '600' }]}>
{saving ? '保存中...' : '保存修改'}
</Text>
</View>
</TouchableOpacity>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
)}
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
keyboardView: {
flex: 1,
},
scrollContent: {
padding: spacing.lg,
},
// ===== 预览区域 - 与 UserProfileHeader 完全一致 =====
previewContainer: {
marginBottom: spacing.lg,
},
coverContainer: {
position: 'relative',
overflow: 'hidden',
borderRadius: borderRadius.lg,
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
gradient: {
width: '100%',
height: '100%',
},
coverUploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '60%',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
marginTop: spacing.xs,
fontWeight: '500',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.paper,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
// 用户信息卡片
profileCard: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: -50,
borderRadius: borderRadius.xl,
padding: spacing.lg,
...shadows.md,
},
avatarWrapper: {
alignItems: 'center',
marginTop: -60,
marginBottom: spacing.md,
},
avatarContainer: {
position: 'relative',
padding: 4,
backgroundColor: colors.background.paper,
borderRadius: 50,
},
avatarUploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
},
editAvatarButton: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
userInfo: {
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
editHint: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
},
// ===== 表单区域 =====
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
shadowColor: colors.text.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
marginBottom: spacing.lg,
},
formField: {
flexDirection: 'row',
alignItems: 'flex-start',
},
fieldIconContainer: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
marginTop: spacing.xs,
},
fieldContent: {
flex: 1,
},
fieldLabel: {
marginBottom: spacing.xs,
fontWeight: '500',
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
marginLeft: 56,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.sm,
},
saveButton: {
backgroundColor: colors.primary.main,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonContent: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
buttonIcon: {
marginRight: spacing.sm,
},
saveButtonText: {
color: colors.text.inverse,
},
});
export default EditProfileScreen;