feat(个人中心): 优化会员横幅组件,支持深色模式与国际化;新增医疗记录卡片组件,完善健康档案功能
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { MembershipBanner } from '@/components/MembershipBanner';
|
||||
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 { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { updateUser, type UserLanguage } from '@/services/users';
|
||||
@@ -56,6 +59,8 @@ type LanguageOption = {
|
||||
};
|
||||
|
||||
export default function PersonalScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
@@ -70,6 +75,11 @@ export default function PersonalScreen() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
|
||||
|
||||
const gradientColors: [string, string] =
|
||||
theme === 'dark'
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
||||
{
|
||||
code: 'zh' as AppLanguage,
|
||||
@@ -350,25 +360,6 @@ export default function PersonalScreen() {
|
||||
</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']
|
||||
@@ -780,15 +771,13 @@ export default function PersonalScreen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
colors={gradientColors}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
@@ -810,7 +799,7 @@ export default function PersonalScreen() {
|
||||
}
|
||||
>
|
||||
<UserHeader />
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
|
||||
<HealthProfileEntry />
|
||||
<BadgesPreviewSection />
|
||||
<View style={styles.fishRecordContainer}>
|
||||
@@ -842,14 +831,6 @@ export default function PersonalScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '60%',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
@@ -876,11 +857,6 @@ const styles = StyleSheet.create({
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
membershipBannerImage: {
|
||||
width: '100%',
|
||||
height: 180,
|
||||
borderRadius: 16,
|
||||
},
|
||||
vipCard: {
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
joinFamilyGroup,
|
||||
selectFamilyGroup,
|
||||
} from '@/store/familyHealthSlice';
|
||||
import { selectHealthHistoryProgress } from '@/store/healthSlice';
|
||||
import {
|
||||
fetchHealthHistory,
|
||||
selectHealthHistoryProgress
|
||||
} from '@/store/healthSlice';
|
||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -25,7 +28,7 @@ import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
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';
|
||||
|
||||
export default function HealthProfileScreen() {
|
||||
@@ -41,12 +44,39 @@ export default function HealthProfileScreen() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||
const [inviteCodeInput, setInviteCodeInput] = useState('');
|
||||
const [relationshipInput, setRelationshipInput] = useState('');
|
||||
const [selectedRelationship, setSelectedRelationship] = useState('');
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [joinError, setJoinError] = useState<string | null>(null);
|
||||
|
||||
// Redux state
|
||||
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
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
@@ -79,16 +109,17 @@ export default function HealthProfileScreen() {
|
||||
return Math.round((filledCount / totalFields) * 100);
|
||||
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
|
||||
|
||||
// 初始化获取家庭组信息
|
||||
// 初始化获取家庭组信息和健康史数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchFamilyGroup());
|
||||
dispatch(fetchHealthHistory());
|
||||
}, [dispatch]);
|
||||
|
||||
// 重置弹窗状态
|
||||
useEffect(() => {
|
||||
if (!joinModalVisible) {
|
||||
setInviteCodeInput('');
|
||||
setRelationshipInput('');
|
||||
setSelectedRelationship('');
|
||||
setJoinError(null);
|
||||
}
|
||||
}, [joinModalVisible]);
|
||||
@@ -107,32 +138,34 @@ export default function HealthProfileScreen() {
|
||||
if (!ok) return;
|
||||
|
||||
const code = inviteCodeInput.trim().toUpperCase();
|
||||
const relationship = relationshipInput.trim();
|
||||
|
||||
if (!code) {
|
||||
setJoinError('请输入邀请码');
|
||||
setJoinError(t('familyGroup.errors.emptyCode'));
|
||||
return;
|
||||
}
|
||||
if (!relationship) {
|
||||
setJoinError('请输入与创建者的关系');
|
||||
if (!selectedRelationship) {
|
||||
setJoinError(t('familyGroup.errors.emptyRelationship'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选中关系的显示文本
|
||||
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
|
||||
|
||||
setIsJoining(true);
|
||||
setJoinError(null);
|
||||
|
||||
try {
|
||||
await dispatch(joinFamilyGroup({ inviteCode: code, relationship })).unwrap();
|
||||
await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
|
||||
await dispatch(fetchFamilyGroup());
|
||||
setJoinModalVisible(false);
|
||||
Toast.success('成功加入家庭组');
|
||||
Toast.success(t('familyGroup.success'));
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
|
||||
setJoinError(message);
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, relationshipInput]);
|
||||
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
|
||||
|
||||
const gradientColors: [string, string] =
|
||||
theme === 'dark'
|
||||
@@ -149,7 +182,7 @@ export default function HealthProfileScreen() {
|
||||
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
|
||||
|
||||
const handleTabPress = (index: number) => {
|
||||
if (index === 4) {
|
||||
if (index === 3) {
|
||||
// Handle Medicine Box tab specially
|
||||
router.push('/medications/manage-medications');
|
||||
return;
|
||||
@@ -245,7 +278,7 @@ export default function HealthProfileScreen() {
|
||||
title={t('health.tabs.healthProfile.medicalRecords')}
|
||||
progress={0}
|
||||
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||
label="0"
|
||||
label={medicalRecordsCount.toString()}
|
||||
suffix="份"
|
||||
/>
|
||||
</View>
|
||||
@@ -307,16 +340,17 @@ export default function HealthProfileScreen() {
|
||||
visible={joinModalVisible}
|
||||
onClose={() => setJoinModalVisible(false)}
|
||||
onConfirm={handleSubmitJoin}
|
||||
title="加入家庭组"
|
||||
description="输入家人分享的邀请码,加入家庭健康管理"
|
||||
confirmText={isJoining ? '加入中...' : '加入'}
|
||||
cancelText="取消"
|
||||
title={t('familyGroup.joinTitle')}
|
||||
description={t('familyGroup.joinDescription')}
|
||||
confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
|
||||
cancelText={t('familyGroup.cancel')}
|
||||
loading={isJoining}
|
||||
content={
|
||||
<View style={styles.modalInputWrapper}>
|
||||
<View style={styles.joinModalContent}>
|
||||
{/* 邀请码输入 */}
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder="请输入邀请码"
|
||||
style={styles.inviteCodeInput}
|
||||
placeholder={t('familyGroup.inviteCodePlaceholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={inviteCodeInput}
|
||||
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
|
||||
@@ -325,15 +359,43 @@ export default function HealthProfileScreen() {
|
||||
keyboardType="default"
|
||||
maxLength={12}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.modalInput, { marginTop: 12 }]}
|
||||
placeholder="与创建者的关系(如:配偶、父母、子女)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={relationshipInput}
|
||||
onChangeText={setRelationshipInput}
|
||||
autoCorrect={false}
|
||||
maxLength={20}
|
||||
/>
|
||||
|
||||
{/* 关系选择标签 */}
|
||||
<Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
|
||||
|
||||
{/* 关系选项网格 - 固定高度可滚动 */}
|
||||
<ScrollView
|
||||
style={styles.relationshipScrollView}
|
||||
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 ? (
|
||||
<Text style={styles.modalError}>{joinError}</Text>
|
||||
) : null}
|
||||
@@ -493,24 +555,62 @@ const styles = StyleSheet.create({
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
modalInputWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 6,
|
||||
// 加入家庭组弹窗样式
|
||||
joinModalContent: {
|
||||
gap: 12,
|
||||
},
|
||||
modalInput: {
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
inviteCodeInput: {
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 2,
|
||||
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: {
|
||||
marginTop: 10,
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
|
||||
177
components/MembershipBanner.tsx
Normal file
177
components/MembershipBanner.tsx
Normal 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' }]
|
||||
}
|
||||
});
|
||||
151
components/health/MedicalRecordCard.tsx
Normal file
151
components/health/MedicalRecordCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -131,10 +131,13 @@ export function HealthHistoryTab() {
|
||||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
|
||||
|
||||
// 初始化时从服务端获取健康史数据
|
||||
// 初始化时从服务端获取健康史数据(如果父组件未加载)
|
||||
useEffect(() => {
|
||||
dispatch(fetchHealthHistory());
|
||||
}, [dispatch]);
|
||||
// 只在数据为空时才主动拉取,避免重复请求
|
||||
if (!historyData || Object.keys(historyData).length === 0) {
|
||||
dispatch(fetchHealthHistory());
|
||||
}
|
||||
}, [dispatch, historyData]);
|
||||
|
||||
const historyItems = [
|
||||
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||
|
||||
@@ -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 React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import dayjs from 'dayjs';
|
||||
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() {
|
||||
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 (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#E5E7EB" />
|
||||
<Text style={styles.emptyText}>暂无就医资料</Text>
|
||||
<Text style={styles.emptySubtext}>上传您的病历、处方单等资料</Text>
|
||||
<View style={styles.container}>
|
||||
{/* Segmented Control */}
|
||||
<View style={styles.segmentContainer}>
|
||||
<TouchableOpacity
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 40,
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
segmentContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 12,
|
||||
padding: 4,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
minHeight: 200,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
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',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 80,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 40,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -725,6 +725,41 @@ export const workoutHistory = {
|
||||
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 = {
|
||||
tabs: {
|
||||
health: 'Health',
|
||||
|
||||
@@ -27,6 +27,11 @@ export const personal = {
|
||||
validForever: 'No expiry',
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
membershipBanner: {
|
||||
title: 'Unlock Premium Access',
|
||||
subtitle: 'Get unlimited access to AI features & custom plans',
|
||||
cta: 'Upgrade Now',
|
||||
},
|
||||
sections: {
|
||||
notifications: 'Notifications',
|
||||
developer: 'Developer',
|
||||
|
||||
@@ -726,6 +726,41 @@ export const workoutHistory = {
|
||||
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 = {
|
||||
tabs: {
|
||||
health: '健康',
|
||||
|
||||
@@ -27,6 +27,11 @@ export const personal = {
|
||||
validForever: '长期有效',
|
||||
dateFormat: 'YYYY年MM月DD日',
|
||||
},
|
||||
membershipBanner: {
|
||||
title: '解锁尊享会员权益',
|
||||
subtitle: '无限次使用 AI 功能,定制专属健康计划',
|
||||
cta: '立即升级',
|
||||
},
|
||||
sections: {
|
||||
notifications: '通知',
|
||||
developer: '开发者',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 70;
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -94,7 +94,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
79E80BBB2EC5D92B004425BE /* Exceptions for "medicine" folder in "medicineExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
@@ -104,7 +104,18 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet 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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
||||
@@ -6,9 +6,9 @@ PODS:
|
||||
- EXImageLoader (6.0.0):
|
||||
- ExpoModulesCore
|
||||
- React-Core
|
||||
- EXNotifications (0.32.12):
|
||||
- EXNotifications (0.32.13):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.25):
|
||||
- Expo (54.0.26):
|
||||
- ExpoModulesCore
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@@ -37,7 +37,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoAsset (12.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoBackgroundTask (1.0.8):
|
||||
- ExpoBackgroundTask (1.0.9):
|
||||
- ExpoModulesCore
|
||||
- ExpoBlur (15.0.7):
|
||||
- ExpoModulesCore
|
||||
@@ -47,6 +47,8 @@ PODS:
|
||||
- ZXingObjC/PDF417
|
||||
- ExpoClipboard (8.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoDocumentPicker (14.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.19):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.9):
|
||||
@@ -55,7 +57,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoHaptics (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoHead (6.0.15):
|
||||
- ExpoHead (6.0.16):
|
||||
- ExpoModulesCore
|
||||
- RNScreens
|
||||
- ExpoImage (3.0.10):
|
||||
@@ -78,7 +80,7 @@ PODS:
|
||||
- ExpoMediaLibrary (18.2.0):
|
||||
- ExpoModulesCore
|
||||
- React-Core
|
||||
- ExpoModulesCore (3.0.26):
|
||||
- ExpoModulesCore (3.0.27):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -105,7 +107,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoSplashScreen (31.0.11):
|
||||
- ExpoModulesCore
|
||||
- ExpoSQLite (16.0.8):
|
||||
- ExpoSQLite (16.0.9):
|
||||
- ExpoModulesCore
|
||||
- ExpoSymbols (1.0.7):
|
||||
- ExpoModulesCore
|
||||
@@ -113,7 +115,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoUI (0.2.0-beta.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.8):
|
||||
- ExpoWebBrowser (15.0.9):
|
||||
- ExpoModulesCore
|
||||
- EXTaskManager (14.0.8):
|
||||
- ExpoModulesCore
|
||||
@@ -163,8 +165,8 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- PurchasesHybridCommon (17.19.1):
|
||||
- RevenueCat (= 5.48.0)
|
||||
- PurchasesHybridCommon (17.21.2):
|
||||
- RevenueCat (= 5.49.2)
|
||||
- RCTDeprecation (0.81.5)
|
||||
- RCTRequired (0.81.5)
|
||||
- RCTTypeSafety (0.81.5):
|
||||
@@ -1446,7 +1448,7 @@ PODS:
|
||||
- ReactNativeDependencies
|
||||
- react-native-render-html (6.3.4):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (5.6.1):
|
||||
- react-native-safe-area-context (5.6.2):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -1458,8 +1460,8 @@ PODS:
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- react-native-safe-area-context/common (= 5.6.1)
|
||||
- react-native-safe-area-context/fabric (= 5.6.1)
|
||||
- react-native-safe-area-context/common (= 5.6.2)
|
||||
- react-native-safe-area-context/fabric (= 5.6.2)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
@@ -1470,7 +1472,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-safe-area-context/common (5.6.1):
|
||||
- react-native-safe-area-context/common (5.6.2):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -1492,7 +1494,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-safe-area-context/fabric (5.6.1):
|
||||
- react-native-safe-area-context/fabric (5.6.2):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -1911,7 +1913,7 @@ PODS:
|
||||
- React-utils (= 0.81.5)
|
||||
- ReactNativeDependencies
|
||||
- ReactNativeDependencies (0.81.5)
|
||||
- RevenueCat (5.48.0)
|
||||
- RevenueCat (5.49.2)
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@@ -2024,10 +2026,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNPurchases (9.6.7):
|
||||
- PurchasesHybridCommon (= 17.19.1)
|
||||
- RNPurchases (9.6.9):
|
||||
- PurchasesHybridCommon (= 17.21.2)
|
||||
- React-Core
|
||||
- RNReanimated (4.1.5):
|
||||
- RNReanimated (4.1.6):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2049,10 +2051,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNReanimated/reanimated (= 4.1.5)
|
||||
- RNReanimated/reanimated (= 4.1.6)
|
||||
- RNWorklets
|
||||
- Yoga
|
||||
- RNReanimated/reanimated (4.1.5):
|
||||
- RNReanimated/reanimated (4.1.6):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2074,10 +2076,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNReanimated/reanimated/apple (= 4.1.5)
|
||||
- RNReanimated/reanimated/apple (= 4.1.6)
|
||||
- RNWorklets
|
||||
- Yoga
|
||||
- RNReanimated/reanimated/apple (4.1.5):
|
||||
- RNReanimated/reanimated/apple (4.1.6):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2217,7 +2219,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNWorklets (0.6.1):
|
||||
- RNWorklets (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2239,9 +2241,9 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNWorklets/worklets (= 0.6.1)
|
||||
- RNWorklets/worklets (= 0.7.1)
|
||||
- Yoga
|
||||
- RNWorklets/worklets (0.6.1):
|
||||
- RNWorklets/worklets (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2263,9 +2265,9 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNWorklets/worklets/apple (= 0.6.1)
|
||||
- RNWorklets/worklets/apple (= 0.7.1)
|
||||
- Yoga
|
||||
- RNWorklets/worklets/apple (0.6.1):
|
||||
- RNWorklets/worklets/apple (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2288,9 +2290,9 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage (5.21.4):
|
||||
- SDWebImage/Core (= 5.21.4)
|
||||
- SDWebImage/Core (5.21.4)
|
||||
- SDWebImage (5.21.5):
|
||||
- SDWebImage/Core (= 5.21.5)
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImageAVIFCoder (0.11.1):
|
||||
- libavif/core (>= 0.11.0)
|
||||
- SDWebImage (~> 5.10)
|
||||
@@ -2320,6 +2322,7 @@ DEPENDENCIES:
|
||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
||||
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
||||
@@ -2467,6 +2470,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/expo-camera/ios"
|
||||
ExpoClipboard:
|
||||
:path: "../node_modules/expo-clipboard/ios"
|
||||
ExpoDocumentPicker:
|
||||
:path: "../node_modules/expo-document-picker/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
@@ -2687,19 +2692,20 @@ SPEC CHECKSUMS:
|
||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
||||
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d
|
||||
EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
|
||||
Expo: 7af24402df45b9384900104e88a11896ffc48161
|
||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
|
||||
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
||||
ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
|
||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
|
||||
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
|
||||
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
|
||||
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
|
||||
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac
|
||||
ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
|
||||
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||
@@ -2707,14 +2713,14 @@ SPEC CHECKSUMS:
|
||||
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
|
||||
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
||||
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
||||
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf
|
||||
ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
|
||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
|
||||
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
||||
ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
|
||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
||||
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
|
||||
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
|
||||
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
|
||||
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
@@ -2723,7 +2729,7 @@ SPEC CHECKSUMS:
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d
|
||||
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||
@@ -2758,7 +2764,7 @@ SPEC CHECKSUMS:
|
||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
||||
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
||||
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
|
||||
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
|
||||
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
|
||||
@@ -2793,20 +2799,20 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57
|
||||
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
|
||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
||||
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
||||
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f
|
||||
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
|
||||
RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
|
||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
||||
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
||||
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
|
||||
5130
package-lock.json
generated
5130
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -17,20 +17,21 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "2.11.4",
|
||||
"@react-native-voice/voice": "^3.2.4",
|
||||
"@react-navigation/bottom-tabs": "^7.8.6",
|
||||
"@react-navigation/elements": "^2.8.3",
|
||||
"@react-navigation/native": "^7.1.21",
|
||||
"@react-navigation/bottom-tabs": "^7.8.11",
|
||||
"@react-navigation/elements": "^2.9.1",
|
||||
"@react-navigation/native": "^7.1.24",
|
||||
"@reduxjs/toolkit": "^2.11.0",
|
||||
"@sentry/react-native": "~7.7.0",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "54.0.25",
|
||||
"expo": "54.0.26",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-background-task": "~1.0.8",
|
||||
"expo-background-task": "~1.0.9",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-glass-effect": "~0.1.7",
|
||||
"expo-haptics": "~15.0.7",
|
||||
@@ -55,7 +56,7 @@
|
||||
"lottie-react-native": "^7.3.4",
|
||||
"react": "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-chart-kit": "^6.12.0",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
@@ -67,7 +68,7 @@
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"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-svg": "15.12.1",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
|
||||
@@ -155,6 +155,54 @@ export async function getHealthHistoryProgress(): Promise<HealthHistoryProgress>
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import * as healthProfileApi from '@/services/healthProfile';
|
||||
import { AppDispatch, RootState } from './index';
|
||||
import {
|
||||
HistoryData,
|
||||
HistoryItemDetail,
|
||||
MedicalRecordItem,
|
||||
MedicalRecordsData,
|
||||
MedicalRecordType,
|
||||
} from '@/services/healthProfile';
|
||||
|
||||
// 健康数据类型定义
|
||||
export interface FitnessRingsData {
|
||||
@@ -45,6 +52,9 @@ export interface HealthState {
|
||||
// 健康史数据
|
||||
historyData: HistoryData;
|
||||
|
||||
// 就医资料数据
|
||||
medicalRecords: MedicalRecordsData;
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -62,6 +72,10 @@ const initialState: HealthState = {
|
||||
surgery: { hasHistory: null, items: [] },
|
||||
familyDisease: { hasHistory: null, items: [] },
|
||||
},
|
||||
medicalRecords: {
|
||||
records: [],
|
||||
prescriptions: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
@@ -136,6 +150,34 @@ const healthSlice = createSlice({
|
||||
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) => {
|
||||
builder
|
||||
@@ -196,6 +238,40 @@ const healthSlice = createSlice({
|
||||
// 获取健康史进度
|
||||
.addCase(fetchHealthHistoryProgress.rejected, (state, action) => {
|
||||
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,
|
||||
setHistoryData,
|
||||
clearHistoryData,
|
||||
setMedicalRecords,
|
||||
addMedicalRecordItem,
|
||||
removeMedicalRecordItem,
|
||||
} = healthSlice.actions;
|
||||
|
||||
// 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
|
||||
export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date];
|
||||
export const selectHealthLoading = (state: RootState) => state.health.loading;
|
||||
export const selectHealthError = (state: RootState) => state.health.error;
|
||||
export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime;
|
||||
export const selectHistoryData = (state: RootState) => state.health.historyData;
|
||||
export const selectMedicalRecords = (state: RootState) => state.health.medicalRecords;
|
||||
|
||||
// 计算健康史完成度的 selector
|
||||
export const selectHealthHistoryProgress = (state: RootState) => {
|
||||
|
||||
Reference in New Issue
Block a user