import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useCosUpload } from '@/hooks/useCosUpload'; import { updateUser as updateUserApi } from '@/services/users'; import { fetchMyProfile } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import DateTimePicker from '@react-native-community/datetimepicker'; import { useFocusEffect } from '@react-navigation/native'; import * as ImagePicker from 'expo-image-picker'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, Image, KeyboardAvoidingView, Modal, Platform, Pressable, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native'; type WeightUnit = 'kg' | 'lb'; type HeightUnit = 'cm' | 'ft'; interface UserProfile { name?: string; gender?: 'male' | 'female' | ''; birthDate?: string; // 出生日期 // 以公制为基准存储 weight?: number; // kg height?: number; // cm avatarUri?: string | null; avatarBase64?: string | null; // 兼容旧逻辑(不再上报) } const STORAGE_KEY = '@user_profile'; export default function EditProfileScreen() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; const dispatch = useAppDispatch(); const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any); const userId: string | undefined = useMemo(() => { return ( accountProfile?.userId || accountProfile?.id || accountProfile?._id || accountProfile?.uid || undefined ) as string | undefined; }, [accountProfile]); const [profile, setProfile] = useState({ name: '', gender: '', birthDate: '', weight: undefined, height: undefined, avatarUri: null, }); const [weightInput, setWeightInput] = useState(''); const [heightInput, setHeightInput] = useState(''); // 出生日期选择器 const [datePickerVisible, setDatePickerVisible] = useState(false); const [pickerDate, setPickerDate] = useState(new Date()); // 输入框字符串 // 从本地存储加载(身高/体重等本地字段) const loadLocalProfile = async () => { try { const [p, fromOnboarding] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEY), AsyncStorage.getItem('@user_personal_info'), ]); let next: UserProfile = { name: '', gender: '', birthDate: '', weight: undefined, height: undefined, avatarUri: null, }; if (fromOnboarding) { try { const o = JSON.parse(fromOnboarding); if (o?.weight) next.weight = parseFloat(o.weight) || undefined; if (o?.height) next.height = parseFloat(o.height) || undefined; if (o?.birthDate) next.birthDate = o.birthDate; if (o?.gender) next.gender = o.gender; } catch { } } if (p) { try { const parsed: UserProfile = JSON.parse(p); next = { ...next, ...parsed }; } catch { } } console.log('loadLocalProfile', next); setProfile((prev) => ({ ...next, avatarUri: prev.avatarUri ?? next.avatarUri ?? null })); setWeightInput(next.weight != null ? String(round(next.weight, 1)) : ''); setHeightInput(next.height != null ? String(Math.round(next.height)) : ''); } catch (e) { console.warn('读取资料失败', e); } }; useEffect(() => { loadLocalProfile(); }, []); // 页面聚焦时拉取最新用户信息,并刷新本地 UI useFocusEffect( React.useCallback(() => { let cancelled = false; (async () => { try { await dispatch(fetchMyProfile() as any); if (!cancelled) { // 拉取完成后,再次从本地存储同步身高/体重等字段 await loadLocalProfile(); } } catch { } })(); return () => { cancelled = true; }; }, [dispatch]) ); // 当全局 profile 更新时,用后端字段覆盖页面 UI 的对应字段(不影响本地身高/体重) useEffect(() => { if (!accountProfile) return; setProfile((prev) => ({ ...prev, name: accountProfile?.name ?? prev.name ?? '', gender: (accountProfile?.gender === 'male' || accountProfile?.gender === 'female') ? accountProfile.gender : (prev.gender ?? ''), avatarUri: accountProfile?.avatar && typeof accountProfile.avatar === 'string' ? (accountProfile.avatar.startsWith('http') || accountProfile.avatar.startsWith('data:') ? accountProfile.avatar : prev.avatarUri) : prev.avatarUri, weight: accountProfile?.weight ?? prev.weight ?? undefined, height: accountProfile?.height ?? prev.height ?? undefined, })); }, [accountProfile]); const textColor = colors.text; const placeholderColor = colors.icon; const handleSave = async () => { try { if (!userId) { Alert.alert('未登录', '请先登录后再尝试保存'); return; } const next: UserProfile = { ...profile }; // 将当前输入同步为公制(固定 kg/cm) const w = parseFloat(weightInput); if (!isNaN(w)) { next.weight = w; } else { next.weight = undefined; } const h = parseFloat(heightInput); if (!isNaN(h)) { next.height = h; } else { next.height = undefined; } await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); // 同步到后端(仅更新后端需要的字段) try { await updateUserApi({ userId, name: next.name || undefined, gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined, // 头像采用已上传的 URL(若有) avatar: next.avatarUri || undefined, weight: next.weight || undefined, height: next.height || undefined, birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined, }); // 拉取最新用户信息,刷新全局状态 await dispatch(fetchMyProfile() as any); } catch (e: any) { // 接口失败不阻断本地保存 console.warn('更新用户信息失败', e?.message || e); } Alert.alert('已保存', '个人资料已更新。'); router.back(); } catch (e) { Alert.alert('保存失败', '请稍后重试'); } }; // 不再需要单位切换 const { upload, uploading } = useCosUpload(); // 出生日期选择器交互 const openDatePicker = () => { const base = profile.birthDate ? new Date(profile.birthDate) : new Date(); base.setHours(0, 0, 0, 0); setPickerDate(base); setDatePickerVisible(true); }; const closeDatePicker = () => setDatePickerVisible(false); const onConfirmDate = (date: Date) => { const today = new Date(); today.setHours(0, 0, 0, 0); const picked = new Date(date); picked.setHours(0, 0, 0, 0); const finalDate = picked > today ? today : picked; setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() })); closeDatePicker(); }; const pickAvatarFromLibrary = async () => { try { const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited'; if (!libGranted) { Alert.alert('权限不足', '需要相册权限以选择头像'); return; } const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: true, quality: 0.9, aspect: [1, 1], mediaTypes: ['images'], base64: false, }); if (!result.canceled) { const asset = result.assets?.[0]; if (!asset?.uri) return; // 直接上传到 COS,成功后写入 URL try { const { url } = await upload( { uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' }, { prefix: 'avatars/', userId } ); setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null })); } catch (e) { console.warn('上传头像失败', e); Alert.alert('上传失败', '头像上传失败,请重试'); } } } catch (e) { Alert.alert('发生错误', '选择头像失败,请重试'); } }; return ( {/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */} router.back()} withSafeTop={false} transparent={true} variant="elevated" /> {/* 头像(带相机蒙层,点击从相册选择) */} {uploading && ( )} {/* 姓名 */} setProfile((p) => ({ ...p, name: t }))} /> {/* 校验勾无需强制,仅装饰 */} {!!profile.name && } {/* 体重(kg) */} kg {/* 身高(cm) */} cm {/* 性别 */} setProfile((p) => ({ ...p, gender: 'female' }))} > 女性 setProfile((p) => ({ ...p, gender: 'male' }))} > 男性 {/* 出生日期 */} {profile.birthDate ? (() => { try { const d = new Date(profile.birthDate); return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d); } catch { return profile.birthDate; } })() : '选择出生日期(可选)'} {/* 出生日期选择器弹窗 */} { if (Platform.OS === 'ios') { if (date) setPickerDate(date); } else { if (event.type === 'set' && date) { onConfirmDate(date); } else { closeDatePicker(); } } }} /> {Platform.OS === 'ios' && ( 取消 { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}> 确定 )} {/* 保存按钮 */} 保存 ); } function FieldLabel({ text }: { text: string }) { return ( {text} ); } // 单位切换组件已移除(固定 kg/cm) function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; } const styles = StyleSheet.create({ container: { flex: 1 }, avatarCircle: { width: 120, height: 120, borderRadius: 60, backgroundColor: '#E8D4F0', alignItems: 'center', justifyContent: 'center', marginBottom: 12, }, avatarImage: { width: 120, height: 120, borderRadius: 60, }, avatarOverlay: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(255,255,255,0.25)', }, avatarLoadingOverlay: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 60, }, inputWrapper: { height: 52, backgroundColor: '#fff', borderRadius: 12, borderWidth: 1, paddingHorizontal: 16, alignItems: 'center', flexDirection: 'row', }, textInput: { flex: 1, fontSize: 16, }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, }, flex1: { flex: 1 }, segmented: { flexDirection: 'row', borderRadius: 12, padding: 4, backgroundColor: '#EFEFEF', }, segmentBtn: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 10, backgroundColor: 'transparent', minWidth: 64, alignItems: 'center', }, selector: { flexDirection: 'row', gap: 12, backgroundColor: '#FFFFFF', borderWidth: 1, borderRadius: 12, padding: 8, }, selectorItem: { flex: 1, height: 48, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, selectorEmoji: { fontSize: 16, marginBottom: 2 }, selectorText: { fontSize: 15, fontWeight: '600' }, saveBtn: { marginTop: 24, height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 4, }, modalBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.35)', }, modalSheet: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 16, backgroundColor: '#FFFFFF', borderTopLeftRadius: 16, borderTopRightRadius: 16, }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8, gap: 12, }, modalBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, backgroundColor: '#F1F5F9', }, modalBtnPrimary: { backgroundColor: '#7a5af8', }, modalBtnText: { color: '#334155', fontWeight: '700', }, modalBtnTextPrimary: { color: '#0F172A', fontWeight: '700', }, });