Files
digital-pilates/app/profile/edit.tsx
richarjiang c12329bc96 feat: 移除目标管理演示页面并优化相关组件
- 删除目标管理演示页面的代码,简化项目结构
- 更新底部导航,移除目标管理演示页面的路由
- 调整相关组件的样式和逻辑,确保界面一致性
- 优化颜色常量的使用,提升视觉效果
2025-08-22 21:24:31 +08:00

586 lines
19 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.

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',
},
});