Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang 21e57634e0 feat(hrv): 添加心率变异性监控和压力评估功能
- 新增 HRV 监听服务,实时监控心率变异性数据
- 实现 HRV 到压力指数的转换算法和压力等级评估
- 添加智能通知服务,在压力偏高时推送健康建议
- 优化日志系统,修复日志丢失问题并增强刷新机制
- 改进个人页面下拉刷新,支持并行数据加载
- 优化勋章数据缓存策略,减少不必要的网络请求
- 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序
- 移除冗余日志输出,提升应用性能
2025-11-18 14:08:20 +08:00

1222 lines
36 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 ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Linking, Modal, RefreshControl, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLanguage, changeAppLanguage, getNormalizedLanguage } from '@/i18n';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
type MenuItem = {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
onPress?: () => void;
type?: 'switch';
switchValue?: boolean;
onSwitchChange?: (value: boolean) => void;
isDanger?: boolean;
rightText?: string;
};
type MenuSectionConfig = {
title: string;
items: MenuItem[];
};
type LanguageOption = {
code: AppLanguage;
label: string;
description: string;
};
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets();
const { t, i18n } = useTranslation();
const router = useRouter();
const isLgAvaliable = isLiquidGlassAvailable();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
code: 'zh' as AppLanguage,
label: t('personal.language.options.zh.label'),
description: t('personal.language.options.zh.description'),
},
{
code: 'en' as AppLanguage,
label: t('personal.language.options.en.label'),
description: t('personal.language.options.en.description'),
},
]), [t]);
const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label ?? '';
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false);
if (language === activeLanguageCode || isSwitchingLanguage) {
return;
}
try {
setIsSwitchingLanguage(true);
await changeAppLanguage(language);
} catch (error) {
log.warn('语言切换失败', error);
} finally {
setIsSwitchingLanguage(false);
}
}, [activeLanguageCode, isSwitchingLanguage]);
// 推送通知设置仅在独立页面管理
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
const handleMembershipPress = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) {
return;
}
openMembershipModal();
}, [ensureLoggedIn, openMembershipModal]);
const handleBadgesPress = useCallback(() => {
router.push(ROUTES.BADGES);
}, [router]);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
}, [insets?.bottom]);
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
const badgePreview = useAppSelector(selectBadgePreview);
const badgeCounts = useAppSelector(selectBadgeCounts);
const sortedBadges = useAppSelector(selectSortedBadges);
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
const autoShownBadgeCodes = useRef<Set<string>>(new Set());
const openBadgeShowcase = useCallback((badge: BadgeDto) => {
autoShownBadgeCodes.current.add(badge.code);
setShowcaseBadge(badge);
}, []);
useEffect(() => {
if (showcaseBadge) {
return;
}
const nextBadgeToShow = sortedBadges.find(
(badge) => badge.isAwarded && badge.isShow === false && !autoShownBadgeCodes.current.has(badge.code)
);
if (nextBadgeToShow) {
openBadgeShowcase(nextBadgeToShow);
}
}, [openBadgeShowcase, showcaseBadge, sortedBadges]);
const handleBadgeShowcaseClose = useCallback(async () => {
if (!showcaseBadge) {
return;
}
const badgeCode = showcaseBadge.code;
setShowcaseBadge(null);
try {
await reportBadgeShowcaseDisplayed(badgeCode);
} catch (error) {
log.warn('report-badge-showcase-failed', error);
}
}, [showcaseBadge]);
console.log('badgePreview', badgePreview);
// 首次加载时获取用户信息和数据
useEffect(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges());
}, [dispatch]);
// 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
useFocusEffect(
useCallback(() => {
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
dispatch(fetchAvailableBadges());
}, [dispatch])
);
// 手动刷新处理
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
// 并行刷新所有数据
await Promise.all([
dispatch(fetchMyProfile()).unwrap(),
dispatch(fetchActivityHistory()).unwrap(),
dispatch(fetchAvailableBadges()).unwrap(),
]);
} catch (error) {
log.warn('刷新数据失败', error);
} finally {
setRefreshing(false);
}
}, [dispatch]);
// 移除 loadNotificationPreference 函数,因为已移到通知设置页面
// 加载开发者模式状态
const loadDeveloperModeState = async () => {
try {
const enabled = await getItem('developer_mode_enabled');
if (enabled === 'true') {
setShowDeveloperSection(true);
}
} catch (error) {
console.error('加载开发者模式状态失败:', error);
}
};
// 保存开发者模式状态
const saveDeveloperModeState = async (enabled: boolean) => {
try {
await setItem('developer_mode_enabled', enabled.toString());
} catch (error) {
console.error('保存开发者模式状态失败:', error);
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}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}${t('personal.stats.ageSuffix')}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
const aiUsageValue = userProfile.isVip ? t('personal.aiUsageUnlimited') : userProfile.freeUsageCount ?? 0;
// 初始化时只加载开发者模式状态
useEffect(() => {
loadDeveloperModeState();
}, []);
// 处理用户名连续点击
const handleUserNamePress = () => {
const now = Date.now();
clickTimestamps.current.push(now);
// 清除之前的超时
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
// 只保留最近1秒内的点击
clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000);
// 检查是否有3次连续点击
if (clickTimestamps.current.length >= 3) {
setShowDeveloperSection(true);
saveDeveloperModeState(true); // 持久化保存开发者模式状态
clickTimestamps.current = []; // 清空点击记录
log.info('开发者模式已激活');
} else {
// 1秒后清空点击记录
clickTimeoutRef.current = setTimeout(() => {
clickTimestamps.current = [];
}, 1000);
}
};
// 移除 handleNotificationToggle 函数,因为已移到通知设置页面
// 用户信息头部
const UserHeader = () => (
<View style={[styles.sectionContainer, {
marginBottom: 0
}]}>
<View style={[styles.userInfoContainer,]}>
<View style={styles.avatarContainer}>
<Image
source={userProfile.avatar || DEFAULT_AVATAR_URL}
style={styles.avatar}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</View>
<View style={styles.userDetails}>
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>
{t('personal.memberNumber', { number: userProfile.memberNumber })}
</Text>
)}
{userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}>
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
<Text style={styles.aiUsageText}>
{t('personal.aiUsage', { value: aiUsageValue })}
</Text>
</View>
)}
</View>
{isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass} isInteractive>
<Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{profileActionLabel}</Text>
</TouchableOpacity>
)}
</View>
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
.map((key) => fallbackProfile[key])
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
const rawExpireDate = userProfile.membershipExpiration ?? fallbackExpire;
let formattedExpire = t('personal.membership.validForever');
if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
const parsed = dayjs(rawExpireDate);
formattedExpire = parsed.isValid() ? parsed.format(t('personal.membership.dateFormat')) : rawExpireDate;
}
const planName =
activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() ||
t('personal.membership.planFallback');
return (
<View style={styles.sectionContainer}>
<LinearGradient
colors={['#5B4CFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.vipCard}
>
<View style={styles.vipCardDecorationLarge} />
<View style={styles.vipCardDecorationSmall} />
<View style={styles.vipCardHeader}>
<View style={styles.vipCardHeaderLeft}>
<View style={styles.vipBadge}>
<Ionicons name="sparkles-outline" as any size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}>{t('personal.membership.badge')}</Text>
</View>
<Text style={styles.vipCardTitle}>{planName}</Text>
</View>
<View style={styles.vipCardIllustration}>
<Ionicons name="ribbon" as any size={32} color="rgba(255,255,255,0.88)" />
</View>
</View>
<View style={styles.vipCardFooter}>
<View style={styles.vipExpiryInfo}>
<Text style={styles.vipExpiryLabel}>{t('personal.membership.expiryLabel')}</Text>
<View style={styles.vipExpiryRow}>
<Ionicons name="time-outline" as any size={16} color="rgba(255,255,255,0.85)" />
<Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
</View>
</View>
<TouchableOpacity
style={styles.vipChangeButton}
activeOpacity={0.85}
onPress={() => {
void handleMembershipPress();
}}
>
<Ionicons name="swap-horizontal-outline" as any size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}>{t('personal.membership.changeButton')}</Text>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
);
};
// 数据统计部分
const StatsSection = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'unset'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</View>
);
const BadgesPreviewSection = () => {
const previewBadges = badgePreview.slice(0, 3);
const hasBadges = previewBadges.length > 0;
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
return (
<View style={styles.sectionContainer}>
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text>
{hasBadges ? (
<View style={styles.badgesRowContent}>
<View style={styles.badgesStack}>
{previewBadges.map((badge, index) => (
<View
key={badge.code}
style={[
styles.badgeCompactBubble,
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
{
marginLeft: index === 0 ? 0 : -12,
zIndex: previewBadges.length - index,
},
]}
>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
/>
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
))}
</View>
{extraCount > 0 && (
<View style={[styles.badgeCompactBubble, styles.badgeExtraBubble, styles.badgeExtraBubbleInline]}>
<Text style={styles.badgeExtraText}>+{extraCount}</Text>
</View>
)}
</View>
) : (
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
)}
</TouchableOpacity>
</View>
);
};
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.cardContainer}>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
onPress={item.type === 'switch' ? undefined : item.onPress}
disabled={item.type === 'switch'}
>
<View style={styles.menuItemLeft}>
<View style={[
styles.iconContainer,
]}>
<Ionicons
name={item.icon}
size={20}
color={item.isDanger ? '#FF4444' : '#9370DB'}
/>
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={item.switchValue || false}
onValueChange={item.onSwitchChange || (() => { })}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<View style={styles.menuRight}>
{item.rightText ? (
<Text style={styles.menuRightText}>{item.rightText}</Text>
) : null}
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
</View>
)}
</TouchableOpacity>
))}
</View>
</View>
);
// 菜单项配置
const menuSections: MenuSectionConfig[] = [
{
title: t('personal.sections.healthData'),
items: [
{
icon: 'medkit-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.healthDataPermissions'),
onPress: () => router.push(ROUTES.HEALTH_DATA_PERMISSIONS),
},
],
},
{
title: t('personal.sections.notifications'),
items: [
{
icon: 'notifications-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.notificationSettings'),
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
},
],
},
{
title: t('personal.sections.medicalSources'),
items: [
{
icon: 'medkit-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.whoSource'),
onPress: () => Linking.openURL('https://www.who.int'),
},
],
},
{
title: t('personal.language.title'),
items: [
{
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.language.menuTitle'),
onPress: () => setLanguageModalVisible(true),
rightText: activeLanguageLabel,
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: t('personal.sections.developer'),
items: [
{
icon: 'code-slash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.developerOptions'),
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
},
{
icon: 'settings-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.pushSettings'),
onPress: () => pushIfAuthedElseLogin(ROUTES.PUSH_NOTIFICATION_SETTINGS),
},
],
}] : []),
{
title: t('personal.sections.other'),
items: [
{
icon: 'shield-checkmark-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.privacyPolicy'),
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
},
{
icon: 'chatbubble-ellipses-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.feedback'),
onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'),
},
{
icon: 'document-text-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.userAgreement'),
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
},
],
},
// 只有登录用户才显示账号与安全菜单
...(isLoggedIn ? [{
title: t('personal.sections.account'),
items: [
{
icon: 'log-out-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.logout'),
onPress: confirmLogout,
isDanger: false,
},
{
icon: 'trash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.deleteAccount'),
onPress: confirmDeleteAccount,
isDanger: true,
},
],
}] : []),
];
const LanguageSelectorModal = () => (
<Modal
animationType="fade"
transparent
visible={languageModalVisible}
onRequestClose={() => setLanguageModalVisible(false)}
>
<View style={styles.languageModalOverlay}>
<TouchableWithoutFeedback onPress={() => setLanguageModalVisible(false)}>
<View style={styles.languageModalBackdrop} />
</TouchableWithoutFeedback>
<View style={styles.languageModalContent}>
<Text style={styles.languageModalTitle}>{t('personal.language.modalTitle')}</Text>
<Text style={styles.languageModalSubtitle}>{t('personal.language.modalSubtitle')}</Text>
{languageOptions.map((option) => {
const isSelected = option.code === activeLanguageCode;
return (
<TouchableOpacity
key={option.code}
activeOpacity={0.85}
style={[
styles.languageOption,
isSelected && styles.languageOptionSelected,
isSwitchingLanguage && styles.languageOptionDisabled,
]}
onPress={() => handleLanguageSelect(option.code)}
disabled={isSwitchingLanguage}
>
<View style={styles.languageOptionTextGroup}>
<Text style={styles.languageOptionLabel}>{option.label}</Text>
<Text style={styles.languageOptionDescription}>{option.description}</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
)}
</TouchableOpacity>
);
})}
<TouchableOpacity
style={styles.languageModalClose}
onPress={() => setLanguageModalVisible(false)}
activeOpacity={0.8}
>
<Text style={styles.languageModalCloseText}>{t('personal.language.cancel')}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: bottomPadding,
paddingHorizontal: 16,
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#9370DB"
colors={['#9370DB']}
progressViewOffset={insets.top}
/>
}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
contentFit="cover"
style={{ width: 16, height: 16, marginLeft: 6 }}
transition={200}
cachePolicy="memory-disk"
/> */}
<Text style={styles.fishRecordText}>{t('personal.fishRecord')}</Text>
</View>
<ActivityHeatMap />
{menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} />
))}
</ScrollView>
<BadgeShowcaseModal
badge={showcaseBadge}
onClose={handleBadgeShowcaseClose}
username={displayName}
appName="Out Live"
/>
<LanguageSelectorModal />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollView: {
flex: 1,
},
// 部分容器
sectionContainer: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 10,
paddingHorizontal: 4,
},
// 卡片容器
cardContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
overflow: 'hidden',
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
vipCardDecorationLarge: {
position: 'absolute',
right: -40,
top: -30,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.12)',
},
vipCardDecorationSmall: {
position: 'absolute',
left: -30,
bottom: -30,
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(255,255,255,0.08)',
},
vipCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
vipCardHeaderLeft: {
flex: 1,
paddingRight: 16,
},
vipBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.18)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 14,
},
vipBadgeText: {
color: '#FFD361',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
vipCardTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginTop: 12,
},
vipCardSubtitle: {
color: 'rgba(255,255,255,0.88)',
fontSize: 12,
lineHeight: 18,
marginTop: 6,
},
vipCardIllustration: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255,255,255,0.18)',
alignItems: 'center',
justifyContent: 'center',
},
vipCardFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
},
vipExpiryInfo: {
flex: 1,
},
vipExpiryLabel: {
color: 'rgba(255,255,255,0.72)',
fontSize: 12,
marginBottom: 6,
},
vipExpiryRow: {
flexDirection: 'row',
alignItems: 'center',
},
vipExpiryValue: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
marginLeft: 6,
},
vipChangeButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
vipChangeButtonText: {
color: '#2F1767',
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
// 用户信息区域
userInfoContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
borderWidth: 2,
borderColor: '#9370DB',
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 4,
},
userRole: {
fontSize: 14,
color: '#9370DB',
fontWeight: '500',
},
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
},
aiUsageContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
aiUsageText: {
fontSize: 10,
color: '#9370DB',
marginLeft: 2,
fontWeight: '500',
},
editButton: {
backgroundColor: '#9370DB',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
},
editButtonGlass: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
editButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)',
fontSize: 14,
fontWeight: '600',
},
// 数据统计
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 16,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#9370DB',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#6C757D',
fontWeight: '500',
},
badgesRowCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderRadius: 16,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
badgesRowTitle: {
fontSize: 16,
fontWeight: '700',
color: '#111827',
},
badgesRowContent: {
flexDirection: 'row',
alignItems: 'center',
},
badgesStack: {
flexDirection: 'row',
alignItems: 'center',
},
badgeCompactBubble: {
width: 32,
height: 32,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
overflow: 'hidden',
position: 'relative',
},
badgeCompactBubbleEarned: {
backgroundColor: 'rgba(16, 185, 129, 0.18)',
},
badgeCompactBubbleLocked: {
backgroundColor: 'rgba(226, 232, 240, 0.9)',
},
badgeCompactImage: {
width: '100%',
height: '100%',
},
badgeCompactFallback: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
badgeCompactFallbackText: {
fontSize: 18,
fontWeight: '600',
color: '#475467',
},
badgeCompactOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15, 23, 42, 0.45)',
alignItems: 'center',
justifyContent: 'center',
},
badgeExtraBubble: {
backgroundColor: 'rgba(124, 58, 237, 0.12)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.35)',
},
badgeExtraBubbleInline: {
marginLeft: 12,
},
badgeExtraText: {
fontSize: 14,
fontWeight: '700',
color: '#5B21B6',
},
badgesRowEmpty: {
fontSize: 13,
color: '#6B7280',
fontWeight: '500',
},
// 菜单项
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
menuRight: {
flexDirection: 'row',
alignItems: 'center',
},
menuRightText: {
fontSize: 13,
color: '#6C757D',
marginRight: 6,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemText: {
fontSize: 15,
color: '#2C3E50',
fontWeight: '500',
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
fishRecordContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
marginBottom: 10,
},
fishRecordText: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
},
languageModalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'stretch',
padding: 24,
},
languageModalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
languageModalContent: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
},
languageModalTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
},
languageModalSubtitle: {
fontSize: 13,
color: '#6C757D',
marginBottom: 4,
},
languageOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 4,
},
languageOptionSelected: {
backgroundColor: 'rgba(147, 112, 219, 0.08)',
borderRadius: 12,
paddingHorizontal: 12,
},
languageOptionDisabled: {
opacity: 0.5,
},
languageOptionTextGroup: {
flex: 1,
paddingRight: 12,
},
languageOptionLabel: {
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
},
languageOptionDescription: {
fontSize: 12,
color: '#6C757D',
marginTop: 4,
},
languageModalClose: {
marginTop: 4,
alignItems: 'center',
},
languageModalCloseText: {
fontSize: 15,
fontWeight: '500',
color: '#9370DB',
},
});