Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang ebc74eb1c8 feat: 优化 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加消息自动滚动功能,提升用户体验
- 更新消息发送逻辑,确保新消息渲染后再滚动
- 在个人信息页面中集成用户资料拉取功能,支持每次聚焦时更新用户信息
- 修改登录页面,增加身份令牌验证,确保安全性
- 更新样式以适应新功能的展示和交互
2025-08-13 16:51:51 +08:00

481 lines
13 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 { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import type { Href } from 'expo-router';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const bottomPadding = useMemo(() => {
// 统一的页面底部留白TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const [notificationEnabled, setNotificationEnabled] = useState(true);
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
type UserProfile = {
fullName?: string;
email?: string;
gender?: 'male' | 'female' | '';
age?: string;
weightKg?: number;
heightCm?: number;
avatarUri?: string | null;
};
const userProfileFromRedux = useAppSelector((s) => s.user.profile);
const [profile, setProfile] = useState<UserProfile>({});
const load = async () => {
try {
const [p, o] = await Promise.all([
AsyncStorage.getItem('@user_profile'),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {};
if (o) {
try {
const parsed = JSON.parse(o);
next = {
...next,
age: parsed?.age ? String(parsed.age) : undefined,
gender: parsed?.gender || '',
heightCm: parsed?.height ? parseFloat(parsed.height) : undefined,
weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined,
};
} catch { }
}
if (p) {
try { next = { ...next, ...JSON.parse(p) }; } catch { }
}
setProfile(next);
} catch (e) {
console.warn('加载用户资料失败', e);
}
};
useEffect(() => { load(); }, []);
useFocusEffect(React.useCallback(() => {
// 每次聚焦时从后端拉取最新用户资料
dispatch(fetchMyProfile());
load();
return () => { };
}, [dispatch]));
useEffect(() => {
if (userProfileFromRedux) {
setProfile(userProfileFromRedux);
}
}, [userProfileFromRedux]);
const formatHeight = () => {
if (profile.heightCm == null) return '--';
return `${Math.round(profile.heightCm)}cm`;
};
const formatWeight = () => {
if (profile.weightKg == null) return '--';
return `${round(profile.weightKg, 1)}kg`;
};
const formatAge = () => (profile.age ? `${profile.age}` : '--');
const round = (n: number, d = 0) => {
const p = Math.pow(10, d); return Math.round(n * p) / p;
};
const handleResetOnboarding = () => {
Alert.alert(
'重置引导',
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '确定',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
} catch (error) {
console.error('重置引导状态失败:', error);
Alert.alert('错误', '重置失败,请稍后重试。');
}
},
},
]
);
};
const displayName = (profile.fullName && profile.fullName.trim()) ? profile.fullName : DEFAULT_MEMBER_NAME;
const UserInfoSection = () => (
<View style={[styles.userInfoCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.userInfoContainer}>
{/* 头像 */}
<View style={styles.avatarContainer}>
<View style={[styles.avatar, { backgroundColor: colorTokens.ornamentAccent }]}>
<Image source={{ uri: profile.avatarUri || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
</View>
</View>
{/* 用户信息 */}
<View style={styles.userDetails}>
<Text style={[styles.userName, { color: colorTokens.text }]}>{displayName}</Text>
</View>
{/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton} onPress={() => router.push('/profile/edit')}>
<Text style={dynamicStyles.editButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
const StatsSection = () => (
<View style={[styles.statsContainer, { backgroundColor: colorTokens.card }]}>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
</View>
);
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={styles.menuItem}
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={[styles.menuIcon, { backgroundColor: 'rgba(187,242,70,0.12)' }]}>
<Ionicons name={item.icon} size={20} color={'#192126'} />
</View>
<Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={notificationEnabled}
onValueChange={setNotificationEnabled}
trackColor={{ false: '#E5E5E5', true: colors.primary }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color={colorTokens.icon} />
)}
</TouchableOpacity>
))}
</View>
);
// 动态创建样式
const dynamicStyles = {
editButton: {
backgroundColor: colors.primary,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
editButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '600' as const,
},
statValue: {
fontSize: 18,
fontWeight: 'bold' as const,
color: colors.primary,
marginBottom: 4,
},
floatingButton: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
alignItems: 'center' as const,
justifyContent: 'center' as const,
shadowColor: colors.primary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
};
const accountItems = [
{
icon: 'flag-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '目标管理',
onPress: () => router.push('/profile/goals' as Href),
},
{
icon: 'stats-chart-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '训练进度',
},
];
const notificationItems = [
{
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '消息推送',
type: 'switch',
},
];
const otherItems = [
{
icon: 'mail-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '联系我们',
},
{
icon: 'shield-checkmark-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '隐私政策',
},
{
icon: 'settings-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '设置',
},
];
const developerItems = [
{
icon: 'refresh-outline',
iconBg: '#FFE8E8',
iconColor: '#FF4444',
title: '重置引导流程',
onPress: handleResetOnboarding,
},
];
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<StatusBar barStyle={theme === 'light' ? 'dark-content' : 'light-content'} backgroundColor="transparent" translucent />
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ScrollView
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
<UserInfoSection />
<StatsSection />
<MenuSection title="账户" items={accountItems} />
<MenuSection title="通知" items={notificationItems} />
<MenuSection title="其他" items={otherItems} />
<MenuSection title="开发者" items={developerItems} />
{/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
<TouchableOpacity style={dynamicStyles.floatingButton}>
<Ionicons name="search" size={24} color="#192126" />
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5', // 浅灰色背景
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
backgroundColor: '#F5F5F5',
},
// 用户信息区域
userInfoCard: {
borderRadius: 16,
marginBottom: 20,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
userInfoContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
},
avatarContainer: {
marginRight: 15,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#E8D4F0',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarContent: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
avatarIcon: {
alignItems: 'center',
justifyContent: 'center',
},
avatarFace: {
width: 25,
height: 25,
borderRadius: 12.5,
backgroundColor: '#D4A574',
marginBottom: 5,
},
avatarBody: {
width: 30,
height: 20,
borderRadius: 15,
backgroundColor: '#F4C842',
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#192126',
marginBottom: 4,
},
// 统计信息区域
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statLabel: {
fontSize: 12,
color: '#687076',
},
// 菜单区域
menuSection: {
marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
color: '#192126',
marginBottom: 12,
paddingHorizontal: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
menuIcon: {
width: 36,
height: 36,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemText: {
fontSize: 16,
color: '#192126',
flex: 1,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
// 浮动按钮
floatingButtonContainer: {
position: 'absolute',
bottom: 30,
left: 0,
right: 0,
alignItems: 'center',
pointerEvents: 'box-none',
},
});