- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
531 lines
15 KiB
TypeScript
531 lines
15 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, { 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 = {
|
||
name?: 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(() => {
|
||
// 聚焦时只拉后端,避免与本地 load 循环触发
|
||
dispatch(fetchMyProfile());
|
||
return () => { };
|
||
}, [dispatch]));
|
||
useEffect(() => {
|
||
const r = userProfileFromRedux as any;
|
||
if (!r) return;
|
||
setProfile((prev) => {
|
||
const next = { ...prev } as any;
|
||
const nameNext = (r.name && String(r.name)) || prev.name;
|
||
const genderNext = (r.gender === 'male' || r.gender === 'female') ? r.gender : (prev.gender ?? '');
|
||
const avatarUriNext = typeof r.avatar === 'string' && (r.avatar.startsWith('http') || r.avatar.startsWith('data:'))
|
||
? r.avatar
|
||
: prev.avatarUri;
|
||
let changed = false;
|
||
if (next.name !== nameNext) { next.name = nameNext; changed = true; }
|
||
if (next.gender !== genderNext) { next.gender = genderNext; changed = true; }
|
||
if (next.avatarUri !== avatarUriNext) { next.avatarUri = avatarUriNext; changed = true; }
|
||
return changed ? next : prev;
|
||
});
|
||
}, [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.name && profile.name.trim()) ? profile.name : DEFAULT_MEMBER_NAME;
|
||
|
||
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: 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 securityItems = [
|
||
{
|
||
icon: 'trash-outline',
|
||
iconBg: '#FFE8E8',
|
||
iconColor: '#FF4444',
|
||
title: '注销帐号',
|
||
onPress: handleDeleteAccount,
|
||
},
|
||
];
|
||
|
||
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={'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 />
|
||
<MenuSection title="账户" items={accountItems} />
|
||
<MenuSection title="通知" items={notificationItems} />
|
||
<MenuSection title="其他" items={otherItems} />
|
||
<MenuSection title="账号与安全" items={securityItems} />
|
||
<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',
|
||
},
|
||
|
||
});
|