- 删除目标管理演示页面的代码,简化项目结构 - 更新底部导航,移除目标管理演示页面的路由 - 调整相关组件的样式和逻辑,确保界面一致性 - 优化颜色常量的使用,提升视觉效果
586 lines
19 KiB
TypeScript
586 lines
19 KiB
TypeScript
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<UserProfile>({
|
||
name: '',
|
||
gender: '',
|
||
birthDate: '',
|
||
weight: undefined,
|
||
height: undefined,
|
||
avatarUri: null,
|
||
});
|
||
|
||
const [weightInput, setWeightInput] = useState<string>('');
|
||
const [heightInput, setHeightInput] = useState<string>('');
|
||
|
||
// 出生日期选择器
|
||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||
const [pickerDate, setPickerDate] = useState<Date>(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 (
|
||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||
<StatusBar barStyle={'dark-content'} />
|
||
|
||
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
||
<HeaderBar
|
||
title="编辑资料"
|
||
onBack={() => router.back()}
|
||
withSafeTop={false}
|
||
transparent={true}
|
||
variant="elevated"
|
||
/>
|
||
|
||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||
|
||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
||
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary} disabled={uploading}>
|
||
<View style={styles.avatarCircle}>
|
||
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} />
|
||
<View style={styles.avatarOverlay}>
|
||
<Ionicons name="camera" size={22} color="#192126" />
|
||
</View>
|
||
{uploading && (
|
||
<View style={styles.avatarLoadingOverlay}>
|
||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||
</View>
|
||
)}
|
||
</View>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 姓名 */}
|
||
<FieldLabel text="姓名" />
|
||
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder="填写姓名(可选)"
|
||
placeholderTextColor={placeholderColor}
|
||
value={profile.name}
|
||
onChangeText={(t) => setProfile((p) => ({ ...p, name: t }))}
|
||
/>
|
||
{/* 校验勾无需强制,仅装饰 */}
|
||
{!!profile.name && <Text style={{ color: '#C4C4C4' }}>✓</Text>}
|
||
</View>
|
||
|
||
{/* 体重(kg) */}
|
||
<FieldLabel text="体重" />
|
||
<View style={styles.row}>
|
||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder={'输入体重'}
|
||
placeholderTextColor={placeholderColor}
|
||
keyboardType="numeric"
|
||
value={weightInput}
|
||
onChangeText={setWeightInput}
|
||
/>
|
||
<Text style={{ color: '#5E6468', marginLeft: 6 }}>kg</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 身高(cm) */}
|
||
<FieldLabel text="身高" />
|
||
<View style={styles.row}>
|
||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder={'输入身高'}
|
||
placeholderTextColor={placeholderColor}
|
||
keyboardType="numeric"
|
||
value={heightInput}
|
||
onChangeText={setHeightInput}
|
||
/>
|
||
<Text style={{ color: '#5E6468', marginLeft: 6 }}>cm</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 性别 */}
|
||
<FieldLabel text="性别" />
|
||
<View style={[styles.selector, { borderColor: '#E0E0E0' }]}>
|
||
<TouchableOpacity
|
||
style={[styles.selectorItem, profile.gender === 'female' && { backgroundColor: colors.primary + '40' }]}
|
||
onPress={() => setProfile((p) => ({ ...p, gender: 'female' }))}
|
||
>
|
||
<Text style={styles.selectorEmoji}>♀</Text>
|
||
<Text style={styles.selectorText}>女性</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.selectorItem, profile.gender === 'male' && { backgroundColor: colors.primary + '40' }]}
|
||
onPress={() => setProfile((p) => ({ ...p, gender: 'male' }))}
|
||
>
|
||
<Text style={styles.selectorEmoji}>♂</Text>
|
||
<Text style={styles.selectorText}>男性</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 出生日期 */}
|
||
<FieldLabel text="出生日期" />
|
||
<TouchableOpacity onPress={openDatePicker} activeOpacity={0.8} style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
|
||
{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;
|
||
}
|
||
})()
|
||
: '选择出生日期(可选)'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
{/* 出生日期选择器弹窗 */}
|
||
<Modal
|
||
visible={datePickerVisible}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={closeDatePicker}
|
||
>
|
||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||
<View style={styles.modalSheet}>
|
||
<DateTimePicker
|
||
value={pickerDate}
|
||
mode="date"
|
||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||
minimumDate={new Date(1900, 0, 1)}
|
||
maximumDate={new Date()}
|
||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||
onChange={(event, date) => {
|
||
if (Platform.OS === 'ios') {
|
||
if (date) setPickerDate(date);
|
||
} else {
|
||
if (event.type === 'set' && date) {
|
||
onConfirmDate(date);
|
||
} else {
|
||
closeDatePicker();
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
{Platform.OS === 'ios' && (
|
||
<View style={styles.modalActions}>
|
||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||
<Text style={styles.modalBtnText}>取消</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Modal>
|
||
|
||
{/* 保存按钮 */}
|
||
<TouchableOpacity onPress={handleSave} activeOpacity={0.9} style={[styles.saveBtn, { backgroundColor: colors.primary }]}>
|
||
<Text style={{ color: colors.onPrimary, fontSize: 18, fontWeight: '700' }}>保存</Text>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function FieldLabel({ text }: { text: string }) {
|
||
return (
|
||
<Text style={{ fontSize: 14, color: '#5E6468', marginBottom: 8, marginTop: 10 }}>{text}</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',
|
||
},
|
||
});
|
||
|
||
|