Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang 705d921c14 feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。

- 新增勋章列表页面,支持已获得和待解锁勋章的分类展示
- 在个人中心添加勋章预览模块,显示前3个勋章和总数统计
- 实现勋章展示弹窗,支持动画效果和玻璃态UI
- 添加勋章分享功能,可生成分享卡片
- 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑
- 添加勋章服务 API 封装,支持获取勋章列表和标记已展示
- 完善中英文国际化文案
2025-11-14 17:17:17 +08:00

1192 lines
35 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, 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 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);
// 页面聚焦时获取最新用户信息
useFocusEffect(
React.useCallback(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges());
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
// 加载开发者模式状态
loadDeveloperModeState();
}, [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}
>
<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',
},
});