feat(个人中心): 优化会员横幅组件,支持深色模式与国际化;新增医疗记录卡片组件,完善健康档案功能

This commit is contained in:
richarjiang
2025-12-05 14:35:10 +08:00
parent f3d4264b53
commit 3d08721474
16 changed files with 3771 additions and 2961 deletions

View File

@@ -1,13 +1,16 @@
import ActivityHeatMap from '@/components/ActivityHeatMap'; import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal'; import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext'; import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import type { BadgeDto } from '@/services/badges'; import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges'; import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users'; import { updateUser, type UserLanguage } from '@/services/users';
@@ -56,6 +59,8 @@ type LanguageOption = {
}; };
export default function PersonalScreen() { export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal(); const { openMembershipModal } = useMembershipModal();
@@ -70,6 +75,11 @@ export default function PersonalScreen() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck(); const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const languageOptions = useMemo<LanguageOption[]>(() => ([ const languageOptions = useMemo<LanguageOption[]>(() => ([
{ {
code: 'zh' as AppLanguage, code: 'zh' as AppLanguage,
@@ -350,25 +360,6 @@ export default function PersonalScreen() {
</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 VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>; const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate'] const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -780,15 +771,13 @@ export default function PersonalScreen() {
); );
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent /> <StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */} {/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={[palette.purple[100], '#F5F5F5']} colors={gradientColors}
start={{ x: 1, y: 0 }} style={StyleSheet.absoluteFillObject}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/> />
<ScrollView <ScrollView
@@ -810,7 +799,7 @@ export default function PersonalScreen() {
} }
> >
<UserHeader /> <UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />} {userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<HealthProfileEntry /> <HealthProfileEntry />
<BadgesPreviewSection /> <BadgesPreviewSection />
<View style={styles.fishRecordContainer}> <View style={styles.fishRecordContainer}>
@@ -842,14 +831,6 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
@@ -876,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2, elevation: 2,
overflow: 'hidden', overflow: 'hidden',
}, },
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: { vipCard: {
borderRadius: 20, borderRadius: 20,
padding: 20, padding: 20,

View File

@@ -16,7 +16,10 @@ import {
joinFamilyGroup, joinFamilyGroup,
selectFamilyGroup, selectFamilyGroup,
} from '@/store/familyHealthSlice'; } from '@/store/familyHealthSlice';
import { selectHealthHistoryProgress } from '@/store/healthSlice'; import {
fetchHealthHistory,
selectHealthHistoryProgress
} from '@/store/healthSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -25,7 +28,7 @@ import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function HealthProfileScreen() { export default function HealthProfileScreen() {
@@ -41,12 +44,39 @@ export default function HealthProfileScreen() {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [joinModalVisible, setJoinModalVisible] = useState(false); const [joinModalVisible, setJoinModalVisible] = useState(false);
const [inviteCodeInput, setInviteCodeInput] = useState(''); const [inviteCodeInput, setInviteCodeInput] = useState('');
const [relationshipInput, setRelationshipInput] = useState(''); const [selectedRelationship, setSelectedRelationship] = useState('');
const [isJoining, setIsJoining] = useState(false); const [isJoining, setIsJoining] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null); const [joinError, setJoinError] = useState<string | null>(null);
// Redux state // Redux state
const familyGroup = useAppSelector(selectFamilyGroup); const familyGroup = useAppSelector(selectFamilyGroup);
const medicalRecords = useAppSelector((state) => state.health.medicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
// Calculate Medical Records Count
const medicalRecordsCount = useMemo(() => records.length + prescriptions.length, [records, prescriptions]);
// 亲属关系选项
const relationshipOptions = useMemo(() => [
{ key: 'spouse', label: t('familyGroup.relationships.spouse') },
{ key: 'father', label: t('familyGroup.relationships.father') },
{ key: 'mother', label: t('familyGroup.relationships.mother') },
{ key: 'son', label: t('familyGroup.relationships.son') },
{ key: 'daughter', label: t('familyGroup.relationships.daughter') },
{ key: 'grandfather', label: t('familyGroup.relationships.grandfather') },
{ key: 'grandmother', label: t('familyGroup.relationships.grandmother') },
{ key: 'grandson', label: t('familyGroup.relationships.grandson') },
{ key: 'granddaughter', label: t('familyGroup.relationships.granddaughter') },
{ key: 'brother', label: t('familyGroup.relationships.brother') },
{ key: 'sister', label: t('familyGroup.relationships.sister') },
{ key: 'uncle', label: t('familyGroup.relationships.uncle') },
{ key: 'aunt', label: t('familyGroup.relationships.aunt') },
{ key: 'nephew', label: t('familyGroup.relationships.nephew') },
{ key: 'niece', label: t('familyGroup.relationships.niece') },
{ key: 'cousin', label: t('familyGroup.relationships.cousin') },
{ key: 'other', label: t('familyGroup.relationships.other') },
], [t]);
// Mock user data - in a real app this would come from Redux/Context // Mock user data - in a real app this would come from Redux/Context
const userProfile = useAppSelector((state) => state.user.profile); const userProfile = useAppSelector((state) => state.user.profile);
@@ -79,16 +109,17 @@ export default function HealthProfileScreen() {
return Math.round((filledCount / totalFields) * 100); return Math.round((filledCount / totalFields) * 100);
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]); }, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
// 初始化获取家庭组信息 // 初始化获取家庭组信息和健康史数据
useEffect(() => { useEffect(() => {
dispatch(fetchFamilyGroup()); dispatch(fetchFamilyGroup());
dispatch(fetchHealthHistory());
}, [dispatch]); }, [dispatch]);
// 重置弹窗状态 // 重置弹窗状态
useEffect(() => { useEffect(() => {
if (!joinModalVisible) { if (!joinModalVisible) {
setInviteCodeInput(''); setInviteCodeInput('');
setRelationshipInput(''); setSelectedRelationship('');
setJoinError(null); setJoinError(null);
} }
}, [joinModalVisible]); }, [joinModalVisible]);
@@ -107,32 +138,34 @@ export default function HealthProfileScreen() {
if (!ok) return; if (!ok) return;
const code = inviteCodeInput.trim().toUpperCase(); const code = inviteCodeInput.trim().toUpperCase();
const relationship = relationshipInput.trim();
if (!code) { if (!code) {
setJoinError('请输入邀请码'); setJoinError(t('familyGroup.errors.emptyCode'));
return; return;
} }
if (!relationship) { if (!selectedRelationship) {
setJoinError('请输入与创建者的关系'); setJoinError(t('familyGroup.errors.emptyRelationship'));
return; return;
} }
// 获取选中关系的显示文本
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
setIsJoining(true); setIsJoining(true);
setJoinError(null); setJoinError(null);
try { try {
await dispatch(joinFamilyGroup({ inviteCode: code, relationship })).unwrap(); await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
await dispatch(fetchFamilyGroup()); await dispatch(fetchFamilyGroup());
setJoinModalVisible(false); setJoinModalVisible(false);
Toast.success('成功加入家庭组'); Toast.success(t('familyGroup.success'));
} catch (error) { } catch (error) {
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确'; const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
setJoinError(message); setJoinError(message);
} finally { } finally {
setIsJoining(false); setIsJoining(false);
} }
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, relationshipInput]); }, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
const gradientColors: [string, string] = const gradientColors: [string, string] =
theme === 'dark' theme === 'dark'
@@ -149,7 +182,7 @@ export default function HealthProfileScreen() {
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"]; const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
const handleTabPress = (index: number) => { const handleTabPress = (index: number) => {
if (index === 4) { if (index === 3) {
// Handle Medicine Box tab specially // Handle Medicine Box tab specially
router.push('/medications/manage-medications'); router.push('/medications/manage-medications');
return; return;
@@ -245,7 +278,7 @@ export default function HealthProfileScreen() {
title={t('health.tabs.healthProfile.medicalRecords')} title={t('health.tabs.healthProfile.medicalRecords')}
progress={0} progress={0}
gradientColors={['#E0E7FF', '#C7D2FE']} gradientColors={['#E0E7FF', '#C7D2FE']}
label="0" label={medicalRecordsCount.toString()}
suffix="份" suffix="份"
/> />
</View> </View>
@@ -307,16 +340,17 @@ export default function HealthProfileScreen() {
visible={joinModalVisible} visible={joinModalVisible}
onClose={() => setJoinModalVisible(false)} onClose={() => setJoinModalVisible(false)}
onConfirm={handleSubmitJoin} onConfirm={handleSubmitJoin}
title="加入家庭组" title={t('familyGroup.joinTitle')}
description="输入家人分享的邀请码,加入家庭健康管理" description={t('familyGroup.joinDescription')}
confirmText={isJoining ? '加入中...' : '加入'} confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
cancelText="取消" cancelText={t('familyGroup.cancel')}
loading={isJoining} loading={isJoining}
content={ content={
<View style={styles.modalInputWrapper}> <View style={styles.joinModalContent}>
{/* 邀请码输入 */}
<TextInput <TextInput
style={styles.modalInput} style={styles.inviteCodeInput}
placeholder="请输入邀请码" placeholder={t('familyGroup.inviteCodePlaceholder')}
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={inviteCodeInput} value={inviteCodeInput}
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())} onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
@@ -325,15 +359,43 @@ export default function HealthProfileScreen() {
keyboardType="default" keyboardType="default"
maxLength={12} maxLength={12}
/> />
<TextInput
style={[styles.modalInput, { marginTop: 12 }]} {/* 关系选择标签 */}
placeholder="与创建者的关系(如:配偶、父母、子女)" <Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
placeholderTextColor="#9ca3af"
value={relationshipInput} {/* 关系选项网格 - 固定高度可滚动 */}
onChangeText={setRelationshipInput} <ScrollView
autoCorrect={false} style={styles.relationshipScrollView}
maxLength={20} contentContainerStyle={styles.relationshipGrid}
/> showsVerticalScrollIndicator={true}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
{relationshipOptions.map((option) => {
const isSelected = selectedRelationship === option.key;
return (
<Pressable
key={option.key}
style={[
styles.relationshipChip,
isSelected && styles.relationshipChipSelected,
]}
onPress={() => setSelectedRelationship(option.key)}
>
<Text
style={[
styles.relationshipChipText,
isSelected && styles.relationshipChipTextSelected,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
{/* 错误提示 */}
{joinError && joinModalVisible ? ( {joinError && joinModalVisible ? (
<Text style={styles.modalError}>{joinError}</Text> <Text style={styles.modalError}>{joinError}</Text>
) : null} ) : null}
@@ -493,24 +555,62 @@ const styles = StyleSheet.create({
joinButtonFallback: { joinButtonFallback: {
backgroundColor: 'rgba(255,255,255,0.7)', backgroundColor: 'rgba(255,255,255,0.7)',
}, },
modalInputWrapper: { // 加入家庭组弹窗样式
borderRadius: 14, joinModalContent: {
borderWidth: 1, gap: 12,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 6,
}, },
modalInput: { inviteCodeInput: {
paddingVertical: 12, backgroundColor: '#f8fafc',
fontSize: 16, borderRadius: 14,
fontWeight: '600', paddingHorizontal: 16,
letterSpacing: 0.5, paddingVertical: 14,
fontSize: 18,
fontWeight: '700',
letterSpacing: 2,
color: '#0f1528', color: '#0f1528',
textAlign: 'center',
},
relationshipLabel: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginTop: 4,
marginBottom: 2,
},
relationshipScrollView: {
maxHeight: 160,
borderRadius: 12,
backgroundColor: '#fafafa',
},
relationshipGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
padding: 8,
},
relationshipChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f3f4f6',
borderWidth: 1.5,
borderColor: 'transparent',
},
relationshipChipSelected: {
backgroundColor: '#ede9fe',
borderColor: '#8b5cf6',
},
relationshipChipText: {
fontSize: 14,
color: '#6b7280',
fontWeight: '500',
},
relationshipChipTextSelected: {
color: '#7c3aed',
fontWeight: '600',
}, },
modalError: { modalError: {
marginTop: 10, marginTop: 6,
fontSize: 12, fontSize: 12,
color: '#ef4444', color: '#ef4444',
}, },

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
interface MembershipBannerProps {
onPress: () => void;
}
export const MembershipBanner: React.FC<MembershipBannerProps> = ({ onPress }) => {
const { t } = useTranslation();
return (
<View style={styles.container}>
<TouchableOpacity
activeOpacity={0.9}
onPress={onPress}
style={styles.touchable}
>
<LinearGradient
colors={['#4C3AFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
{/* Decorative Elements */}
<View style={styles.decorationCircleLarge} />
<View style={styles.decorationCircleSmall} />
<View style={styles.contentContainer}>
<View style={styles.textContainer}>
<View style={styles.badgeContainer}>
<Ionicons name="crown" size={10} color="#FFD700" style={styles.badgeIcon} />
<Text style={styles.badgeText}>PRO</Text>
</View>
<Text style={styles.title}>
{t('personal.membershipBanner.title', 'Unlock Premium Access')}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{t('personal.membershipBanner.subtitle', 'Get unlimited access to all features')}
</Text>
<View style={styles.ctaButton}>
<Text style={styles.ctaText}>{t('personal.membershipBanner.cta', 'Upgrade')}</Text>
<Ionicons name="arrow-forward" size={12} color="#4C3AFF" />
</View>
</View>
<View style={styles.illustrationContainer}>
{/* Use Ionicons as illustration or you can use Image if passed as prop */}
<Ionicons name="diamond-outline" size={56} color="rgba(255,255,255,0.15)" />
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 20,
borderRadius: 16,
// Premium Shadow
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
marginHorizontal: 4, // Add margin to avoid cutting off shadow
},
touchable: {
borderRadius: 16,
overflow: 'hidden',
},
gradient: {
padding: 16,
minHeight: 100,
position: 'relative',
justifyContent: 'center',
},
decorationCircleLarge: {
position: 'absolute',
top: -40,
right: -40,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.08)',
},
decorationCircleSmall: {
position: 'absolute',
bottom: -30,
left: -30,
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255,255,255,0.05)',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1,
},
textContainer: {
flex: 1,
paddingRight: 12,
zIndex: 2,
},
badgeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
alignSelf: 'flex-start',
marginBottom: 8,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.3)',
},
badgeIcon: {
marginRight: 3,
},
badgeText: {
color: '#FFD700',
fontSize: 9,
fontWeight: '800',
letterSpacing: 0.5,
fontFamily: 'AliBold',
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
marginBottom: 4,
fontFamily: 'AliBold',
lineHeight: 20,
},
subtitle: {
fontSize: 11,
color: 'rgba(255,255,255,0.9)',
marginBottom: 12,
lineHeight: 14,
fontFamily: 'AliRegular',
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
alignSelf: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
ctaText: {
color: '#4C3AFF',
fontSize: 11,
fontWeight: '700',
marginRight: 4,
fontFamily: 'AliBold',
},
illustrationContainer: {
position: 'absolute',
right: -6,
bottom: -6,
zIndex: 1,
transform: [{ rotate: '-15deg' }]
}
});

View File

@@ -0,0 +1,151 @@
import { Colors, palette } from '@/constants/Colors';
import { MedicalRecordItem } from '@/services/healthProfile';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MedicalRecordCardProps {
item: MedicalRecordItem;
onPress: (item: MedicalRecordItem) => void;
onDelete: (item: MedicalRecordItem) => void;
}
export const MedicalRecordCard: React.FC<MedicalRecordCardProps> = ({ item, onPress, onDelete }) => {
const firstAttachment = item.images && item.images.length > 0 ? item.images[0] : null;
const isPdf = firstAttachment?.toLowerCase().endsWith('.pdf');
return (
<TouchableOpacity
style={styles.container}
onPress={() => onPress(item)}
activeOpacity={0.8}
>
<View style={styles.thumbnailContainer}>
{firstAttachment ? (
isPdf ? (
<View style={styles.pdfThumbnail}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText}>PDF</Text>
</View>
) : (
<Image
source={{ uri: firstAttachment }}
style={styles.thumbnail}
contentFit="cover"
transition={200}
/>
)
) : (
<View style={styles.placeholderThumbnail}>
<Ionicons name="document-text-outline" size={32} color={palette.gray[300]} />
</View>
)}
{item.images && item.images.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>+{item.images.length - 1}</Text>
</View>
)}
</View>
<View style={styles.content}>
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
<Text style={styles.date}>{dayjs(item.date).format('YYYY-MM-DD')}</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => onDelete(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={16} color={palette.gray[400]} />
</TouchableOpacity>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: palette.gray[200],
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 2,
overflow: 'hidden',
flexDirection: 'row',
height: 100,
},
thumbnailContainer: {
width: 100,
height: '100%',
backgroundColor: palette.gray[50],
position: 'relative',
},
thumbnail: {
width: '100%',
height: '100%',
},
pdfThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
placeholderThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.gray[50],
},
badge: {
position: 'absolute',
right: 8,
bottom: 8,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
},
badgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
content: {
flex: 1,
padding: 12,
justifyContent: 'center',
},
title: {
fontSize: 16,
fontWeight: '600',
color: palette.gray[800],
marginBottom: 4,
fontFamily: 'AliBold',
},
date: {
fontSize: 12,
color: palette.purple[600],
fontWeight: '500',
fontFamily: 'AliRegular',
},
deleteButton: {
position: 'absolute',
top: 8,
right: 8,
padding: 4,
},
});

View File

@@ -131,10 +131,13 @@ export function HealthHistoryTab() {
const [isDatePickerVisible, setDatePickerVisibility] = useState(false); const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null); const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
// 初始化时从服务端获取健康史数据 // 初始化时从服务端获取健康史数据(如果父组件未加载)
useEffect(() => { useEffect(() => {
// 只在数据为空时才主动拉取,避免重复请求
if (!historyData || Object.keys(historyData).length === 0) {
dispatch(fetchHealthHistory()); dispatch(fetchHealthHistory());
}, [dispatch]); }
}, [dispatch, historyData]);
const historyItems = [ const historyItems = [
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' }, { title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },

View File

@@ -1,49 +1,647 @@
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
import {
addNewMedicalRecord,
deleteMedicalRecordItem,
fetchMedicalRecords,
selectHealthLoading,
selectMedicalRecords,
} from '@/store/healthSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import React from 'react'; import dayjs from 'dayjs';
import { StyleSheet, Text, View } from 'react-native'; import * as DocumentPicker from 'expo-document-picker';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
export function MedicalRecordsTab() { export function MedicalRecordsTab() {
const dispatch = useAppDispatch();
const medicalRecords = useAppSelector(selectMedicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
const isLoading = useAppSelector(selectHealthLoading);
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
const [isModalVisible, setModalVisible] = useState(false);
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
// Form State
const [title, setTitle] = useState('');
const [date, setDate] = useState(new Date());
const [images, setImages] = useState<string[]>([]);
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Image Viewer State
const [viewerVisible, setViewerVisible] = useState(false);
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
useEffect(() => {
dispatch(fetchMedicalRecords());
}, [dispatch]);
const currentList = activeTab === 'medical_record' ? records : prescriptions;
const handleTabPress = (tab: MedicalRecordType) => {
setActiveTab(tab);
};
const resetForm = () => {
setTitle('');
setDate(new Date());
setImages([]);
setNote('');
};
const openAddModal = () => {
resetForm();
setModalVisible(true);
};
const handlePickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相册以上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handleTakePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handlePickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
} catch (error) {
console.error('Error picking document:', error);
Alert.alert('错误', '选择文件失败');
}
};
const handleSubmit = async () => {
if (!title.trim()) {
Alert.alert('提示', '请输入标题');
return;
}
if (images.length === 0) {
Alert.alert('提示', '请至少上传一张图片');
return;
}
setIsSubmitting(true);
try {
await dispatch(addNewMedicalRecord({
type: activeTab,
title,
date: dayjs(date).format('YYYY-MM-DD'),
images,
note,
})).unwrap();
setModalVisible(false);
} catch (error) {
Alert.alert('错误', '保存失败,请重试');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = (item: MedicalRecordItem) => {
Alert.alert(
'确认删除',
'确定要删除这条记录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
},
]
);
};
const handleViewImages = (item: MedicalRecordItem) => {
if (item.images && item.images.length > 0) {
setCurrentViewerImages(item.images.map(uri => ({ uri })));
setViewerVisible(true);
}
};
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
<MedicalRecordCard
item={item}
onPress={handleViewImages}
onDelete={handleDelete}
/>
);
return ( return (
<View style={styles.card}> <View style={styles.container}>
<View style={styles.emptyState}> {/* Segmented Control */}
<Ionicons name="folder-open-outline" size={48} color="#E5E7EB" /> <View style={styles.segmentContainer}>
<Text style={styles.emptyText}></Text> <TouchableOpacity
<Text style={styles.emptySubtext}></Text> style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
onPress={() => handleTabPress('medical_record')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
onPress={() => handleTabPress('prescription')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
</View> </View>
{/* Content List */}
<View style={styles.contentContainer}>
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={palette.purple[500]} />
</View>
) : currentList.length > 0 ? (
<FlatList
data={currentList}
renderItem={renderItem}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
scrollEnabled={false} // Since it's inside a parent ScrollView
/>
) : (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
size={48}
color={palette.gray[300]}
/>
</View>
<Text style={styles.emptyText}>
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
</Text>
<Text style={styles.emptySubtext}>
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
</Text>
</View>
)}
</View>
{/* Add Button */}
<TouchableOpacity
style={styles.fab}
onPress={openAddModal}
activeOpacity={0.9}
>
<LinearGradient
colors={[palette.purple[500], palette.purple[700]]}
style={styles.fabGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="add" size={28} color="#FFFFFF" />
</LinearGradient>
</TouchableOpacity>
{/* Add/Edit Modal */}
<Modal
visible={isModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
<Text style={styles.modalCloseText}></Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
</Text>
<TouchableOpacity
onPress={handleSubmit}
style={[styles.modalSaveButton, isSubmitting && styles.modalSaveButtonDisabled]}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.modalSaveText}></Text>
)}
</TouchableOpacity>
</View>
<View style={styles.formContainer}>
{/* Title Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<TextInput
style={styles.input}
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
value={title}
onChangeText={setTitle}
placeholderTextColor={palette.gray[400]}
/>
</View>
{/* Date Picker */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TouchableOpacity
style={styles.dateInput}
onPress={() => setDatePickerVisibility(true)}
>
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
</TouchableOpacity>
</View>
{/* Images */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<View style={styles.imageGrid}>
{images.map((uri, index) => {
const isPdf = uri.toLowerCase().endsWith('.pdf');
return (
<View key={index} style={styles.imagePreviewContainer}>
{isPdf ? (
<View style={[styles.imagePreview, styles.pdfPreview]}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
</View>
) : (
<Image
source={{ uri }}
style={styles.imagePreview}
contentFit="cover"
/>
)}
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => setImages(images.filter((_, i) => i !== index))}
>
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
</TouchableOpacity>
</View>
);
})}
{images.length < 9 && (
<TouchableOpacity style={styles.addImageButton} onPress={() => {
Alert.alert(
'上传文件',
'请选择上传方式',
[
{ text: '拍照', onPress: handleTakePhoto },
{ text: '从相册选择', onPress: handlePickImage },
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
{ text: '取消', style: 'cancel' },
]
);
}}>
<Ionicons name="add" size={32} color={palette.purple[500]} />
<Text style={styles.addImageText}></Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Note */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="添加备注信息..."
value={note}
onChangeText={setNote}
multiline
numberOfLines={4}
placeholderTextColor={palette.gray[400]}
textAlignVertical="top"
/>
</View>
</View>
</View>
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="date"
onConfirm={(d) => {
setDate(d);
setDatePickerVisibility(false);
}}
onCancel={() => setDatePickerVisibility(false)}
maximumDate={new Date()}
locale="zh_CN"
confirmTextIOS="确定"
cancelTextIOS="取消"
/>
</Modal>
<ImageViewing
images={currentViewerImages}
imageIndex={0}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { container: {
backgroundColor: '#FFFFFF', flex: 1,
borderRadius: 20, },
padding: 40, segmentContainer: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 4,
marginBottom: 16, marginBottom: 16,
shadowColor: '#000', },
shadowOffset: { width: 0, height: 2 }, segmentButton: {
shadowOpacity: 0.03, flex: 1,
shadowRadius: 6, paddingVertical: 10,
elevation: 1,
minHeight: 200,
alignItems: 'center', alignItems: 'center',
borderRadius: 10,
},
segmentButtonActive: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
segmentText: {
fontSize: 14,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
segmentTextActive: {
color: palette.purple[600],
fontWeight: '600',
fontFamily: 'AliBold',
},
contentContainer: {
minHeight: 300,
},
loadingContainer: {
flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
listContent: {
paddingBottom: 80,
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginTop: 40,
paddingHorizontal: 40,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
}, },
emptyText: { emptyText: {
marginTop: 16,
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: '#374151', color: '#374151',
marginBottom: 8,
fontFamily: 'AliBold', fontFamily: 'AliBold',
}, },
emptySubtext: { emptySubtext: {
marginTop: 8,
fontSize: 13, fontSize: 13,
color: '#9CA3AF', color: '#9CA3AF',
textAlign: 'center',
lineHeight: 20,
fontFamily: 'AliRegular',
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
fabGradient: {
width: '100%',
height: '100%',
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
// Modal Styles
modalContainer: {
flex: 1,
backgroundColor: '#F9FAFB',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB',
paddingTop: Platform.OS === 'ios' ? 12 : 12,
},
modalCloseButton: {
padding: 8,
},
modalCloseText: {
fontSize: 16,
color: '#6B7280',
fontFamily: 'AliRegular',
},
modalTitle: {
fontSize: 17,
fontWeight: '600',
color: '#111827',
fontFamily: 'AliBold',
},
modalSaveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: palette.purple[600],
borderRadius: 6,
},
modalSaveButtonDisabled: {
opacity: 0.6,
},
modalSaveText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
formContainer: {
padding: 16,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '500',
color: '#374151',
marginBottom: 8,
fontFamily: 'AliRegular',
},
required: {
color: palette.error[500],
},
input: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#111827',
borderWidth: 1,
borderColor: '#E5E7EB',
fontFamily: 'AliRegular',
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
dateInput: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
},
dateText: {
fontSize: 16,
color: '#111827',
fontFamily: 'AliRegular',
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
imagePreviewContainer: {
width: 80,
height: 80,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
imagePreview: {
width: '100%',
height: '100%',
},
pdfPreview: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
removeImageButton: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: 'rgba(255,255,255,0.8)',
borderRadius: 10,
},
addImageButton: {
width: 80,
height: 80,
borderRadius: 8,
borderWidth: 1,
borderColor: palette.purple[200],
borderStyle: 'dashed',
backgroundColor: palette.purple[50],
alignItems: 'center',
justifyContent: 'center',
},
addImageText: {
fontSize: 12,
color: palette.purple[600],
marginTop: 4,
fontFamily: 'AliRegular', fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -725,6 +725,41 @@ export const workoutHistory = {
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.', monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
}; };
export const familyGroup = {
joinTitle: 'Join Family Group',
joinDescription: 'Enter the invite code shared by your family member to join health management',
inviteCodePlaceholder: 'Enter invite code',
relationshipLabel: 'Relationship to creator',
relationshipPlaceholder: 'Select relationship',
joinButton: 'Join',
joining: 'Joining...',
cancel: 'Cancel',
errors: {
emptyCode: 'Please enter invite code',
emptyRelationship: 'Please select relationship',
},
success: 'Successfully joined family group',
relationships: {
spouse: 'Spouse',
father: 'Father',
mother: 'Mother',
son: 'Son',
daughter: 'Daughter',
grandfather: 'Grandfather',
grandmother: 'Grandmother',
grandson: 'Grandson',
granddaughter: 'Granddaughter',
brother: 'Brother',
sister: 'Sister',
uncle: 'Uncle',
aunt: 'Aunt',
nephew: 'Nephew',
niece: 'Niece',
cousin: 'Cousin',
other: 'Other',
},
};
export const health = { export const health = {
tabs: { tabs: {
health: 'Health', health: 'Health',

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: 'No expiry', validForever: 'No expiry',
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
}, },
membershipBanner: {
title: 'Unlock Premium Access',
subtitle: 'Get unlimited access to AI features & custom plans',
cta: 'Upgrade Now',
},
sections: { sections: {
notifications: 'Notifications', notifications: 'Notifications',
developer: 'Developer', developer: 'Developer',

View File

@@ -726,6 +726,41 @@ export const workoutHistory = {
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。', monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
}; };
export const familyGroup = {
joinTitle: '加入家庭组',
joinDescription: '输入家人分享的邀请码,加入家庭健康管理',
inviteCodePlaceholder: '请输入邀请码',
relationshipLabel: '与创建者的关系',
relationshipPlaceholder: '请选择关系',
joinButton: '加入',
joining: '加入中...',
cancel: '取消',
errors: {
emptyCode: '请输入邀请码',
emptyRelationship: '请选择与创建者的关系',
},
success: '成功加入家庭组',
relationships: {
spouse: '配偶',
father: '父亲',
mother: '母亲',
son: '儿子',
daughter: '女儿',
grandfather: '爷爷/外公',
grandmother: '奶奶/外婆',
grandson: '孙子/外孙',
granddaughter: '孙女/外孙女',
brother: '兄弟',
sister: '姐妹',
uncle: '叔叔/舅舅',
aunt: '阿姨/姑姑',
nephew: '侄子/外甥',
niece: '侄女/外甥女',
cousin: '表/堂兄弟姐妹',
other: '其他',
},
};
export const health = { export const health = {
tabs: { tabs: {
health: '健康', health: '健康',

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: '长期有效', validForever: '长期有效',
dateFormat: 'YYYY年MM月DD日', dateFormat: 'YYYY年MM月DD日',
}, },
membershipBanner: {
title: '解锁尊享会员权益',
subtitle: '无限次使用 AI 功能,定制专属健康计划',
cta: '立即升级',
},
sections: { sections: {
notifications: '通知', notifications: '通知',
developer: '开发者', developer: '开发者',

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 60;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -94,7 +94,7 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { 79E80BBB2EC5D92B004425BE /* Exceptions for "medicine" folder in "medicineExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Info.plist, Info.plist,
@@ -104,7 +104,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
79E80BA72EC5D92A004425BE /* medicine */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = medicine; sourceTree = "<group>"; }; 79E80BA72EC5D92A004425BE /* medicine */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
79E80BBB2EC5D92B004425BE /* Exceptions for "medicine" folder in "medicineExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = medicine;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */

View File

@@ -6,9 +6,9 @@ PODS:
- EXImageLoader (6.0.0): - EXImageLoader (6.0.0):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
- EXNotifications (0.32.12): - EXNotifications (0.32.13):
- ExpoModulesCore - ExpoModulesCore
- Expo (54.0.25): - Expo (54.0.26):
- ExpoModulesCore - ExpoModulesCore
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
@@ -37,7 +37,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoAsset (12.0.10): - ExpoAsset (12.0.10):
- ExpoModulesCore - ExpoModulesCore
- ExpoBackgroundTask (1.0.8): - ExpoBackgroundTask (1.0.9):
- ExpoModulesCore - ExpoModulesCore
- ExpoBlur (15.0.7): - ExpoBlur (15.0.7):
- ExpoModulesCore - ExpoModulesCore
@@ -47,6 +47,8 @@ PODS:
- ZXingObjC/PDF417 - ZXingObjC/PDF417
- ExpoClipboard (8.0.7): - ExpoClipboard (8.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoDocumentPicker (14.0.7):
- ExpoModulesCore
- ExpoFileSystem (19.0.19): - ExpoFileSystem (19.0.19):
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (14.0.9): - ExpoFont (14.0.9):
@@ -55,7 +57,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoHaptics (15.0.7): - ExpoHaptics (15.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoHead (6.0.15): - ExpoHead (6.0.16):
- ExpoModulesCore - ExpoModulesCore
- RNScreens - RNScreens
- ExpoImage (3.0.10): - ExpoImage (3.0.10):
@@ -78,7 +80,7 @@ PODS:
- ExpoMediaLibrary (18.2.0): - ExpoMediaLibrary (18.2.0):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
- ExpoModulesCore (3.0.26): - ExpoModulesCore (3.0.27):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -105,7 +107,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoSplashScreen (31.0.11): - ExpoSplashScreen (31.0.11):
- ExpoModulesCore - ExpoModulesCore
- ExpoSQLite (16.0.8): - ExpoSQLite (16.0.9):
- ExpoModulesCore - ExpoModulesCore
- ExpoSymbols (1.0.7): - ExpoSymbols (1.0.7):
- ExpoModulesCore - ExpoModulesCore
@@ -113,7 +115,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoUI (0.2.0-beta.7): - ExpoUI (0.2.0-beta.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoWebBrowser (15.0.8): - ExpoWebBrowser (15.0.9):
- ExpoModulesCore - ExpoModulesCore
- EXTaskManager (14.0.8): - EXTaskManager (14.0.8):
- ExpoModulesCore - ExpoModulesCore
@@ -163,8 +165,8 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- PurchasesHybridCommon (17.19.1): - PurchasesHybridCommon (17.21.2):
- RevenueCat (= 5.48.0) - RevenueCat (= 5.49.2)
- RCTDeprecation (0.81.5) - RCTDeprecation (0.81.5)
- RCTRequired (0.81.5) - RCTRequired (0.81.5)
- RCTTypeSafety (0.81.5): - RCTTypeSafety (0.81.5):
@@ -1446,7 +1448,7 @@ PODS:
- ReactNativeDependencies - ReactNativeDependencies
- react-native-render-html (6.3.4): - react-native-render-html (6.3.4):
- React-Core - React-Core
- react-native-safe-area-context (5.6.1): - react-native-safe-area-context (5.6.2):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -1458,8 +1460,8 @@ PODS:
- React-graphics - React-graphics
- React-ImageManager - React-ImageManager
- React-jsi - React-jsi
- react-native-safe-area-context/common (= 5.6.1) - react-native-safe-area-context/common (= 5.6.2)
- react-native-safe-area-context/fabric (= 5.6.1) - react-native-safe-area-context/fabric (= 5.6.2)
- React-NativeModulesApple - React-NativeModulesApple
- React-RCTFabric - React-RCTFabric
- React-renderercss - React-renderercss
@@ -1470,7 +1472,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- react-native-safe-area-context/common (5.6.1): - react-native-safe-area-context/common (5.6.2):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -1492,7 +1494,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- react-native-safe-area-context/fabric (5.6.1): - react-native-safe-area-context/fabric (5.6.2):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -1911,7 +1913,7 @@ PODS:
- React-utils (= 0.81.5) - React-utils (= 0.81.5)
- ReactNativeDependencies - ReactNativeDependencies
- ReactNativeDependencies (0.81.5) - ReactNativeDependencies (0.81.5)
- RevenueCat (5.48.0) - RevenueCat (5.49.2)
- RNCAsyncStorage (2.2.0): - RNCAsyncStorage (2.2.0):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
@@ -2024,10 +2026,10 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- RNPurchases (9.6.7): - RNPurchases (9.6.9):
- PurchasesHybridCommon (= 17.19.1) - PurchasesHybridCommon (= 17.21.2)
- React-Core - React-Core
- RNReanimated (4.1.5): - RNReanimated (4.1.6):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2049,10 +2051,10 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- RNReanimated/reanimated (= 4.1.5) - RNReanimated/reanimated (= 4.1.6)
- RNWorklets - RNWorklets
- Yoga - Yoga
- RNReanimated/reanimated (4.1.5): - RNReanimated/reanimated (4.1.6):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2074,10 +2076,10 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.1.5) - RNReanimated/reanimated/apple (= 4.1.6)
- RNWorklets - RNWorklets
- Yoga - Yoga
- RNReanimated/reanimated/apple (4.1.5): - RNReanimated/reanimated/apple (4.1.6):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2217,7 +2219,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- RNWorklets (0.6.1): - RNWorklets (0.7.1):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2239,9 +2241,9 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- RNWorklets/worklets (= 0.6.1) - RNWorklets/worklets (= 0.7.1)
- Yoga - Yoga
- RNWorklets/worklets (0.6.1): - RNWorklets/worklets (0.7.1):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2263,9 +2265,9 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- RNWorklets/worklets/apple (= 0.6.1) - RNWorklets/worklets/apple (= 0.7.1)
- Yoga - Yoga
- RNWorklets/worklets/apple (0.6.1): - RNWorklets/worklets/apple (0.7.1):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
- RCTTypeSafety - RCTTypeSafety
@@ -2288,9 +2290,9 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- SDWebImage (5.21.4): - SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.4) - SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.4) - SDWebImage/Core (5.21.5)
- SDWebImageAVIFCoder (0.11.1): - SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0) - libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10) - SDWebImage (~> 5.10)
@@ -2320,6 +2322,7 @@ DEPENDENCIES:
- ExpoBlur (from `../node_modules/expo-blur/ios`) - ExpoBlur (from `../node_modules/expo-blur/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`) - ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`) - ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`) - ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`) - ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
@@ -2467,6 +2470,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-camera/ios" :path: "../node_modules/expo-camera/ios"
ExpoClipboard: ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios" :path: "../node_modules/expo-clipboard/ios"
ExpoDocumentPicker:
:path: "../node_modules/expo-document-picker/ios"
ExpoFileSystem: ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios" :path: "../node_modules/expo-file-system/ios"
ExpoFont: ExpoFont:
@@ -2687,19 +2692,20 @@ SPEC CHECKSUMS:
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7 EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506 EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d Expo: 7af24402df45b9384900104e88a11896ffc48161
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940 ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365 ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961 ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566 ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
@@ -2707,14 +2713,14 @@ SPEC CHECKSUMS:
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031 ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3 ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68 ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7 ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804 ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0 ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271 EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
@@ -2723,7 +2729,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
@@ -2758,7 +2764,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513 react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2 react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
@@ -2793,20 +2799,20 @@ SPEC CHECKSUMS:
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57 RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035 RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1 RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0 RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1 RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380

5130
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,21 @@
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "2.11.4", "@react-native-picker/picker": "2.11.4",
"@react-native-voice/voice": "^3.2.4", "@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.8.6", "@react-navigation/bottom-tabs": "^7.8.11",
"@react-navigation/elements": "^2.8.3", "@react-navigation/elements": "^2.9.1",
"@react-navigation/native": "^7.1.21", "@react-navigation/native": "^7.1.24",
"@reduxjs/toolkit": "^2.11.0", "@reduxjs/toolkit": "^2.11.0",
"@sentry/react-native": "~7.7.0", "@sentry/react-native": "~7.7.0",
"@types/lodash": "^4.17.21", "@types/lodash": "^4.17.21",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"expo": "54.0.25", "expo": "54.0.26",
"expo-apple-authentication": "~8.0.7", "expo-apple-authentication": "~8.0.7",
"expo-background-task": "~1.0.8", "expo-background-task": "~1.0.9",
"expo-blur": "~15.0.7", "expo-blur": "~15.0.7",
"expo-clipboard": "~8.0.7",
"expo-camera": "~17.0.9", "expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-document-picker": "~14.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-glass-effect": "~0.1.7", "expo-glass-effect": "~0.1.7",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
@@ -55,7 +56,7 @@
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^16.3.0", "react-i18next": "^16.3.5",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
@@ -67,7 +68,7 @@
"react-native-purchases": "^9.2.2", "react-native-purchases": "^9.2.2",
"react-native-reanimated": "~4.1.0", "react-native-reanimated": "~4.1.0",
"react-native-render-html": "^6.3.4", "react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "~5.6.1", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",

View File

@@ -155,6 +155,54 @@ export async function getHealthHistoryProgress(): Promise<HealthHistoryProgress>
return api.get<HealthHistoryProgress>('/api/health-profiles/history/progress'); return api.get<HealthHistoryProgress>('/api/health-profiles/history/progress');
} }
// ==================== 就医资料 API ====================
export type MedicalRecordType = 'medical_record' | 'prescription';
export interface MedicalRecordItem {
id: string;
type: MedicalRecordType;
title: string;
date: string; // YYYY-MM-DD
images: string[]; // Image URLs
note?: string;
}
export interface MedicalRecordsData {
records: MedicalRecordItem[];
prescriptions: MedicalRecordItem[];
}
/**
* 获取就医资料列表
*/
export async function getMedicalRecords(): Promise<MedicalRecordsData> {
// Mock implementation for now
// return api.get<MedicalRecordsData>('/api/health-profiles/medical-records');
return {
records: [],
prescriptions: []
};
}
/**
* 添加就医资料
*/
export async function addMedicalRecord(data: Omit<MedicalRecordItem, 'id'>): Promise<MedicalRecordItem> {
// return api.post<MedicalRecordItem>('/api/health-profiles/medical-records', data);
return {
id: Date.now().toString(),
...data
};
}
/**
* 删除就医资料
*/
export async function deleteMedicalRecord(id: string): Promise<void> {
// return api.delete(`/api/health-profiles/medical-records/${id}`);
}
// ==================== 家庭健康管理 API ==================== // ==================== 家庭健康管理 API ====================
/** /**

View File

@@ -1,6 +1,13 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import * as healthProfileApi from '@/services/healthProfile'; import * as healthProfileApi from '@/services/healthProfile';
import { AppDispatch, RootState } from './index'; import { AppDispatch, RootState } from './index';
import {
HistoryData,
HistoryItemDetail,
MedicalRecordItem,
MedicalRecordsData,
MedicalRecordType,
} from '@/services/healthProfile';
// 健康数据类型定义 // 健康数据类型定义
export interface FitnessRingsData { export interface FitnessRingsData {
@@ -45,6 +52,9 @@ export interface HealthState {
// 健康史数据 // 健康史数据
historyData: HistoryData; historyData: HistoryData;
// 就医资料数据
medicalRecords: MedicalRecordsData;
// 加载状态 // 加载状态
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@@ -62,6 +72,10 @@ const initialState: HealthState = {
surgery: { hasHistory: null, items: [] }, surgery: { hasHistory: null, items: [] },
familyDisease: { hasHistory: null, items: [] }, familyDisease: { hasHistory: null, items: [] },
}, },
medicalRecords: {
records: [],
prescriptions: [],
},
loading: false, loading: false,
error: null, error: null,
lastUpdateTime: null, lastUpdateTime: null,
@@ -136,6 +150,34 @@ const healthSlice = createSlice({
familyDisease: { hasHistory: null, items: [] }, familyDisease: { hasHistory: null, items: [] },
}; };
}, },
// 更新就医资料列表
setMedicalRecords: (state, action: PayloadAction<MedicalRecordsData>) => {
state.medicalRecords = action.payload;
state.lastUpdateTime = new Date().toISOString();
},
// 添加就医资料项
addMedicalRecordItem: (state, action: PayloadAction<MedicalRecordItem>) => {
const item = action.payload;
if (item.type === 'medical_record') {
state.medicalRecords.records.unshift(item);
} else {
state.medicalRecords.prescriptions.unshift(item);
}
state.lastUpdateTime = new Date().toISOString();
},
// 删除就医资料项
removeMedicalRecordItem: (state, action: PayloadAction<{ id: string; type: MedicalRecordType }>) => {
const { id, type } = action.payload;
if (type === 'medical_record') {
state.medicalRecords.records = state.medicalRecords.records.filter(item => item.id !== id);
} else {
state.medicalRecords.prescriptions = state.medicalRecords.prescriptions.filter(item => item.id !== id);
}
state.lastUpdateTime = new Date().toISOString();
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -196,6 +238,40 @@ const healthSlice = createSlice({
// 获取健康史进度 // 获取健康史进度
.addCase(fetchHealthHistoryProgress.rejected, (state, action) => { .addCase(fetchHealthHistoryProgress.rejected, (state, action) => {
state.error = action.payload as string; state.error = action.payload as string;
})
// 获取就医资料
.addCase(fetchMedicalRecords.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchMedicalRecords.fulfilled, (state, action) => {
state.loading = false;
state.medicalRecords = action.payload;
state.lastUpdateTime = new Date().toISOString();
})
.addCase(fetchMedicalRecords.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// 添加就医资料
.addCase(addNewMedicalRecord.fulfilled, (state, action) => {
const item = action.payload;
if (item.type === 'medical_record') {
state.medicalRecords.records.unshift(item);
} else {
state.medicalRecords.prescriptions.unshift(item);
}
state.lastUpdateTime = new Date().toISOString();
})
// 删除就医资料
.addCase(deleteMedicalRecordItem.fulfilled, (state, action) => {
const { id, type } = action.payload;
if (type === 'medical_record') {
state.medicalRecords.records = state.medicalRecords.records.filter(item => item.id !== id);
} else {
state.medicalRecords.prescriptions = state.medicalRecords.prescriptions.filter(item => item.id !== id);
}
state.lastUpdateTime = new Date().toISOString();
}); });
}, },
}); });
@@ -210,6 +286,9 @@ export const {
updateHistoryData, updateHistoryData,
setHistoryData, setHistoryData,
clearHistoryData, clearHistoryData,
setMedicalRecords,
addMedicalRecordItem,
removeMedicalRecordItem,
} = healthSlice.actions; } = healthSlice.actions;
// Thunk action to fetch and set health data for a specific date // Thunk action to fetch and set health data for a specific date
@@ -286,12 +365,60 @@ export const fetchHealthHistoryProgress = createAsyncThunk(
} }
); );
// ==================== 就医资料 API Thunks ====================
/**
* 获取就医资料
*/
export const fetchMedicalRecords = createAsyncThunk(
'health/fetchMedicalRecords',
async (_, { rejectWithValue }) => {
try {
const data = await healthProfileApi.getMedicalRecords();
return data;
} catch (err: any) {
return rejectWithValue(err?.message ?? '获取就医资料失败');
}
}
);
/**
* 添加就医资料
*/
export const addNewMedicalRecord = createAsyncThunk(
'health/addMedicalRecord',
async (data: Omit<MedicalRecordItem, 'id'>, { rejectWithValue }) => {
try {
const result = await healthProfileApi.addMedicalRecord(data);
return result;
} catch (err: any) {
return rejectWithValue(err?.message ?? '添加就医资料失败');
}
}
);
/**
* 删除就医资料
*/
export const deleteMedicalRecordItem = createAsyncThunk(
'health/deleteMedicalRecord',
async ({ id, type }: { id: string; type: MedicalRecordType }, { rejectWithValue }) => {
try {
await healthProfileApi.deleteMedicalRecord(id);
return { id, type };
} catch (err: any) {
return rejectWithValue(err?.message ?? '删除就医资料失败');
}
}
);
// Selectors // Selectors
export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date]; export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date];
export const selectHealthLoading = (state: RootState) => state.health.loading; export const selectHealthLoading = (state: RootState) => state.health.loading;
export const selectHealthError = (state: RootState) => state.health.error; export const selectHealthError = (state: RootState) => state.health.error;
export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime; export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime;
export const selectHistoryData = (state: RootState) => state.health.historyData; export const selectHistoryData = (state: RootState) => state.health.historyData;
export const selectMedicalRecords = (state: RootState) => state.health.medicalRecords;
// 计算健康史完成度的 selector // 计算健康史完成度的 selector
export const selectHealthHistoryProgress = (state: RootState) => { export const selectHealthHistoryProgress = (state: RootState) => {