Files
digital-pilates/app/profile/edit.tsx
richarjiang e3e2f1b8c6 feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
2025-08-14 09:57:13 +08:00

467 lines
15 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 { 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 {
Alert,
Image,
KeyboardAvoidingView,
Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type WeightUnit = 'kg' | 'lb';
type HeightUnit = 'cm' | 'ft';
interface UserProfile {
name?: string;
gender?: 'male' | 'female' | '';
age?: 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 insets = useSafeAreaInsets();
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: '',
age: '',
weight: undefined,
height: undefined,
avatarUri: null,
});
const [weightInput, setWeightInput] = useState<string>('');
const [heightInput, setHeightInput] = useState<string>('');
// 输入框字符串
// 从本地存储加载(身高/体重等本地字段)
const loadLocalProfile = async () => {
try {
const [p, fromOnboarding] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEY),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {
name: '',
gender: '',
age: '',
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?.age) next.age = String(o.age);
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,
});
// 拉取最新用户信息,刷新全局状态
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 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: ImagePicker.MediaTypeOptions.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={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
{/* 头像(带相机蒙层,点击从相册选择) */}
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary}>
<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>
</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="年龄" />
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder="填写年龄(可选)"
placeholderTextColor={placeholderColor}
keyboardType="number-pad"
value={profile.age ?? ''}
onChangeText={(t) => setProfile((p) => ({ ...p, age: t }))}
/>
</View>
{/* 保存按钮 */}
<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
// 工具函数
// 转换函数不再使用,保留 round
function kgToLb(kg: number) { return kg * 2.2046226218; }
function lbToKg(lb: number) { return lb / 2.2046226218; }
function cmToFt(cm: number) { return cm / 30.48; }
function ftToCm(ft: number) { return ft * 30.48; }
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)',
},
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,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 0,
marginBottom: 8,
},
backButton: { padding: 4, width: 32 },
headerTitle: { fontSize: 18, fontWeight: '700', color: '#192126' },
});