feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
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, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
@@ -30,9 +35,10 @@ interface UserProfile {
|
||||
gender?: 'male' | 'female' | '';
|
||||
age?: string; // 存储为字符串,方便非必填
|
||||
// 以公制为基准存储
|
||||
weightKg?: number; // kg
|
||||
heightCm?: number; // cm
|
||||
weight?: number; // kg
|
||||
height?: number; // cm
|
||||
avatarUri?: string | null;
|
||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||||
}
|
||||
|
||||
const STORAGE_KEY = '@user_profile';
|
||||
@@ -41,13 +47,24 @@ 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: '',
|
||||
weightKg: undefined,
|
||||
heightCm: undefined,
|
||||
weight: undefined,
|
||||
height: undefined,
|
||||
avatarUri: null,
|
||||
});
|
||||
|
||||
@@ -56,72 +73,128 @@ export default function EditProfileScreen() {
|
||||
|
||||
// 输入框字符串
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
// 读取已保存资料;兼容引导页的个人信息
|
||||
const [p, fromOnboarding] = await Promise.all([
|
||||
AsyncStorage.getItem(STORAGE_KEY),
|
||||
AsyncStorage.getItem('@user_personal_info'),
|
||||
]);
|
||||
// 从本地存储加载(身高/体重等本地字段)
|
||||
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);
|
||||
|
||||
let next: UserProfile = {
|
||||
name: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
weightKg: undefined,
|
||||
heightCm: undefined,
|
||||
avatarUri: null,
|
||||
};
|
||||
|
||||
if (fromOnboarding) {
|
||||
try {
|
||||
const o = JSON.parse(fromOnboarding);
|
||||
if (o?.weight) next.weightKg = parseFloat(o.weight) || undefined;
|
||||
if (o?.height) next.heightCm = 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 { }
|
||||
}
|
||||
setProfile(next);
|
||||
setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : '');
|
||||
setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : '');
|
||||
} catch (e) {
|
||||
console.warn('读取资料失败', e);
|
||||
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.weightKg = w;
|
||||
next.weight = w;
|
||||
} else {
|
||||
next.weightKg = undefined;
|
||||
next.weight = undefined;
|
||||
}
|
||||
|
||||
const h = parseFloat(heightInput);
|
||||
if (!isNaN(h)) {
|
||||
next.heightCm = h;
|
||||
next.height = h;
|
||||
} else {
|
||||
next.heightCm = undefined;
|
||||
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) {
|
||||
@@ -131,6 +204,8 @@ export default function EditProfileScreen() {
|
||||
|
||||
// 不再需要单位切换
|
||||
|
||||
const { upload, uploading } = useCosUpload();
|
||||
|
||||
const pickAvatarFromLibrary = async () => {
|
||||
try {
|
||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -144,9 +219,22 @@ export default function EditProfileScreen() {
|
||||
quality: 0.9,
|
||||
aspect: [1, 1],
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
base64: false,
|
||||
});
|
||||
if (!result.canceled) {
|
||||
setProfile((p) => ({ ...p, avatarUri: result.assets?.[0]?.uri ?? null }));
|
||||
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('发生错误', '选择头像失败,请重试');
|
||||
|
||||
Reference in New Issue
Block a user