Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang f95401c1ce feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议
- 在训练计划中集成排课功能,允许用户选择和安排训练动作
- 更新个人信息页面,添加出生日期字段,支持用户完善个人资料
- 优化训练计划卡片样式,提升用户体验
- 更新相关依赖,确保项目兼容性和功能完整性
2025-08-15 10:45:37 +08:00

448 lines
13 KiB
TypeScript

import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { api } from '@/services/api';
import { DEFAULT_MEMBER_NAME, fetchMyProfile, logout } 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, { 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';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const colorScheme = useColorScheme();
const [notificationEnabled, setNotificationEnabled] = useState(true);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
// 颜色主题
const colors = Colors[colorScheme ?? 'light'];
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
// 页面聚焦时获取最新用户信息
useFocusEffect(
React.useCallback(() => {
dispatch(fetchMyProfile());
}, [dispatch])
);
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${Math.round(userProfile.height)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${Math.round(userProfile.weight * 10) / 10}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
// 颜色令牌
const colorTokens = colors;
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 handleDeleteAccount = () => {
Alert.alert(
'确认注销帐号',
'此操作不可恢复,将删除您的帐号及相关数据。确定继续吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '确认注销',
style: 'destructive',
onPress: async () => {
try {
await api.delete('/api/users/delete-account');
await AsyncStorage.multiRemove(['@user_personal_info']);
await dispatch(logout()).unwrap();
Alert.alert('帐号已注销', '您的帐号已成功注销');
router.replace('/auth/login');
} catch (err: any) {
const message = err?.message || '注销失败,请稍后重试';
Alert.alert('注销失败', message);
}
},
},
],
{ cancelable: true }
);
};
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: userProfile.avatar || 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: item.isDanger ? 'rgba(255,68,68,0.12)' : 'rgba(187,242,70,0.12)' }
]}>
<Ionicons
name={item.icon}
size={20}
color={item.isDanger ? colors.danger : colors.onPrimary}
/>
</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 = StyleSheet.create({
editButton: {
backgroundColor: colors.primary,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
editButtonText: {
color: colors.onPrimary,
fontSize: 14,
fontWeight: '600',
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
color: colors.primary,
marginBottom: 4,
},
floatingButton: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
});
// 菜单项配置
const menuSections = [
{
title: '账户',
items: [
{
icon: 'flag-outline' as const,
title: '目标管理',
onPress: () => router.push('/profile/goals' as Href),
},
{
icon: 'stats-chart-outline' as const,
title: '训练进度',
},
],
},
{
title: '通知',
items: [
{
icon: 'notifications-outline' as const,
title: '消息推送',
type: 'switch' as const,
},
],
},
{
title: '其他',
items: [
{
icon: 'mail-outline' as const,
title: '联系我们',
},
{
icon: 'shield-checkmark-outline' as const,
title: '隐私政策',
},
{
icon: 'settings-outline' as const,
title: '设置',
},
],
},
{
title: '账号与安全',
items: [
{
icon: 'trash-outline' as const,
title: '注销帐号',
onPress: handleDeleteAccount,
isDanger: true,
},
],
},
{
title: '开发者',
items: [
{
icon: 'refresh-outline' as const,
title: '重置引导流程',
onPress: handleResetOnboarding,
isDanger: true,
},
],
},
];
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<StatusBar barStyle={'dark-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 />
{menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} />
))}
{/* 底部浮动按钮 */}
<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,
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
// 用户信息区域
userInfoCard: {
borderRadius: 16,
marginBottom: 20,
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,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
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,
},
menuSection: {
marginBottom: 20,
padding: 16,
borderRadius: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
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,
flex: 1,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
// 浮动按钮
floatingButtonContainer: {
position: 'absolute',
bottom: 30,
left: 0,
right: 0,
alignItems: 'center',
pointerEvents: 'box-none',
},
});