Files
frontend/src/screens/profile/EditProfileScreen.tsx

816 lines
23 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, useMemo } 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 {
spacing,
fontSizes,
borderRadius,
shadows,
useAppColors,
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores';
import { Avatar, Button, Text } from '../../components/common';
import { authService, uploadService } from '../../services';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
function createEditProfileStyles(colors: AppColors) {
return 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: {
...StyleSheet.absoluteFillObject,
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,
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
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,
},
});
}
type EditProfileSheet = ReturnType<typeof createEditProfileStyles>;
// 表单输入项组件
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;
sheet: EditProfileSheet;
colors: AppColors;
}
const FormField: React.FC<FormFieldProps> = ({
icon,
label,
value,
onChangeText,
placeholder,
maxLength,
multiline = false,
numberOfLines = 1,
autoCapitalize = 'sentences',
keyboardType = 'default',
editable = true,
sheet,
colors,
}) => {
return (
<View style={sheet.formField}>
<View style={sheet.fieldIconContainer}>
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} />
</View>
<View style={sheet.fieldContent}>
<Text variant="caption" style={sheet.fieldLabel}>{label}</Text>
<TextInput
style={[
sheet.fieldInput,
multiline && sheet.textArea,
!editable && sheet.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 colors = useAppColors();
const styles = useMemo(() => createEditProfileStyles(colors), [colors]);
const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
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}
sheet={styles}
colors={colors}
/>
<View style={styles.divider} />
<FormField
icon="information-outline"
label="个人简介"
value={bio}
onChangeText={setBio}
placeholder="介绍一下自己,让大家更了解你"
maxLength={100}
multiline
numberOfLines={3}
sheet={styles}
colors={colors}
/>
<View style={styles.divider} />
<FormField
icon="map-marker-outline"
label="地区"
value={location}
onChangeText={setLocation}
placeholder="你所在的城市"
maxLength={30}
sheet={styles}
colors={colors}
/>
<View style={styles.divider} />
<FormField
icon="link-variant"
label="个人网站"
value={website}
onChangeText={setWebsite}
placeholder="https://example.com"
maxLength={100}
autoCapitalize="none"
keyboardType="url"
sheet={styles}
colors={colors}
/>
</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"
sheet={styles}
colors={colors}
/>
<View style={styles.divider} />
<FormField
icon="email-outline"
label="邮箱"
value={email}
onChangeText={setEmail}
placeholder="请输入邮箱地址"
maxLength={100}
autoCapitalize="none"
keyboardType="email-address"
sheet={styles}
colors={colors}
/>
</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}
>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default EditProfileScreen;