feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
|||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
},
|
},
|
||||||
"kiroAgent.configureMCP": "Enabled"
|
"kiroAgent.configureMCP": "Enabled",
|
||||||
|
"codingcopilot.enableCompletionLanguage": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ type TabConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
|
||||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
|
||||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
|
||||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
|
||||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
|||||||
@@ -260,25 +260,6 @@ export default function PersonalScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据格式化函数
|
|
||||||
const formatHeight = () => {
|
|
||||||
if (userProfile.height == null) return '--';
|
|
||||||
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatWeight = () => {
|
|
||||||
if (userProfile.weight == null) return '--';
|
|
||||||
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAge = () => {
|
|
||||||
if (!userProfile.birthDate) return '--';
|
|
||||||
const birthDate = new Date(userProfile.birthDate);
|
|
||||||
const today = new Date();
|
|
||||||
const age = today.getFullYear() - birthDate.getFullYear();
|
|
||||||
return `${age}${t('personal.stats.ageSuffix')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显示名称
|
// 显示名称
|
||||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
||||||
@@ -454,27 +435,33 @@ export default function PersonalScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据统计部分
|
// 健康档案入口组件
|
||||||
const StatsSection = () => (
|
const HealthProfileEntry = () => (
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<View style={[styles.cardContainer, {
|
<TouchableOpacity
|
||||||
backgroundColor: 'transparent'
|
style={styles.healthProfileCard}
|
||||||
}]}>
|
activeOpacity={0.9}
|
||||||
<View style={styles.statsContainer}>
|
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
|
||||||
<View style={styles.statItem}>
|
>
|
||||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
<LinearGradient
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
|
colors={['#FFFFFF', '#F0F4FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.healthProfileGradient}
|
||||||
|
>
|
||||||
|
<View style={styles.healthProfileContent}>
|
||||||
|
<View style={styles.healthProfileLeft}>
|
||||||
|
<View style={styles.healthProfileTitleRow}>
|
||||||
|
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.healthProfileRight}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statItem}>
|
</LinearGradient>
|
||||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -824,7 +811,7 @@ export default function PersonalScreen() {
|
|||||||
>
|
>
|
||||||
<UserHeader />
|
<UserHeader />
|
||||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||||
<StatsSection />
|
<HealthProfileEntry />
|
||||||
<BadgesPreviewSection />
|
<BadgesPreviewSection />
|
||||||
<View style={styles.fishRecordContainer}>
|
<View style={styles.fishRecordContainer}>
|
||||||
{/* <Image
|
{/* <Image
|
||||||
@@ -1315,4 +1302,60 @@ const styles = StyleSheet.create({
|
|||||||
color: '#9370DB',
|
color: '#9370DB',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
|
// 健康档案入口样式
|
||||||
|
healthProfileCard: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
healthProfileGradient: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
healthProfileContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
healthProfileLeft: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
healthProfileTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
healthProfileTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginRight: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
healthStatusBadge: {
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#A7F3D0',
|
||||||
|
},
|
||||||
|
healthStatusText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#059669',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
healthProfileSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
healthProfileRight: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -653,9 +653,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
hrvTestButton: {
|
|
||||||
backgroundColor: '#8B5CF6',
|
|
||||||
},
|
|
||||||
debugButtonText: {
|
debugButtonText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
|
|||||||
626
app/health/family-invite.tsx
Normal file
626
app/health/family-invite.tsx
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import {
|
||||||
|
createFamilyGroup,
|
||||||
|
fetchFamilyGroup,
|
||||||
|
generateInviteCode,
|
||||||
|
selectFamilyGroup,
|
||||||
|
selectFamilyHealthLoading,
|
||||||
|
selectInviteCode,
|
||||||
|
selectInviteLoading,
|
||||||
|
} from '@/store/familyHealthSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
Share,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function FamilyInviteScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [agreed, setAgreed] = useState(true);
|
||||||
|
const [showQRModal, setShowQRModal] = useState(false);
|
||||||
|
|
||||||
|
// Redux state
|
||||||
|
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||||
|
const inviteCode = useAppSelector(selectInviteCode);
|
||||||
|
const isLoading = useAppSelector(selectFamilyHealthLoading);
|
||||||
|
const isInviteLoading = useAppSelector(selectInviteLoading);
|
||||||
|
|
||||||
|
// 初始化时获取家庭组信息
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchFamilyGroup());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 处理邀请按钮点击
|
||||||
|
const handleInvite = async () => {
|
||||||
|
try {
|
||||||
|
// 如果没有家庭组,先创建一个
|
||||||
|
if (!familyGroup) {
|
||||||
|
await dispatch(createFamilyGroup('我的家庭')).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成邀请码
|
||||||
|
await dispatch(generateInviteCode(24)).unwrap();
|
||||||
|
|
||||||
|
// 显示二维码弹窗
|
||||||
|
setShowQRModal(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('邀请失败', error?.message || '请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分享邀请码
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (!inviteCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: `邀请您加入我的家庭健康管理组!\n邀请码:${inviteCode.inviteCode}\n有效期至:${new Date(inviteCode.expiresAt).toLocaleString()}`,
|
||||||
|
title: '家庭健康管理邀请',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分享失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<HeaderBar title="" transparent />
|
||||||
|
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#Eef2FF', '#F5F3FF', '#FFFFFF']}
|
||||||
|
style={styles.background}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 40 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header Title Area */}
|
||||||
|
<View style={styles.headerSection}>
|
||||||
|
<Text style={styles.mainTitle}>家庭健康管理</Text>
|
||||||
|
<Text style={styles.mainTitle}>保障全家健康</Text>
|
||||||
|
|
||||||
|
<View style={styles.subtitleBadge}>
|
||||||
|
<Ionicons name="home" size={12} color="#5B4CFF" />
|
||||||
|
<Text style={styles.subtitleText}>全家互相督促,让关爱不遗漏</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero Image / House Icon Area */}
|
||||||
|
<View style={styles.heroContainer}>
|
||||||
|
{/* Floating Labels */}
|
||||||
|
<View style={[styles.floatingLabel, styles.labelLeft]}>
|
||||||
|
<Text style={styles.floatingLabelText}>实时管理</Text>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.floatingLabel, styles.labelRight]}>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<Text style={styles.floatingLabelText}>守护家庭健康</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main 3D House Icon Placeholder */}
|
||||||
|
<View style={styles.houseIconPlaceholder}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#A78BFA', '#5B4CFF']}
|
||||||
|
style={styles.houseIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={60} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Features Grid */}
|
||||||
|
<View style={styles.featuresCard}>
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#EEF2FF' }]}>
|
||||||
|
<Ionicons name="share-social" size={24} color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>数据共享</Text>
|
||||||
|
<Text style={styles.featureDesc}>家人档案共同维护</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#FEF2F2' }]}>
|
||||||
|
<Ionicons name="alert-circle" size={24} color="#EF4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>异常提醒</Text>
|
||||||
|
<Text style={styles.featureDesc}>数据异常实时提醒</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#FFF7ED' }]}>
|
||||||
|
<Ionicons name="medkit" size={24} color="#F97316" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>用药监督</Text>
|
||||||
|
<Text style={styles.featureDesc}>用药情况远程监督</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Steps Section */}
|
||||||
|
<View style={styles.stepsContainer}>
|
||||||
|
<Text style={styles.stepsTitle}>简单3步,帮家人管理档案</Text>
|
||||||
|
<View style={styles.stepsSubtitleContainer}>
|
||||||
|
<Text style={styles.stepsSubtitle}>最多邀请6人,分享二维码有效期24小时</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.stepsRow}>
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>1</Text>
|
||||||
|
<Text style={styles.stepDesc}>分享二维码邀请</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||||
|
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>2</Text>
|
||||||
|
<Text style={styles.stepDesc}>家人下载登录App</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||||
|
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>3</Text>
|
||||||
|
<Text style={styles.stepDesc}>扫二维码加入</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom Spacing */}
|
||||||
|
<View style={{ height: 120 }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Bottom Action Area */}
|
||||||
|
<View style={[styles.bottomArea, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkboxRow}
|
||||||
|
onPress={() => setAgreed(!agreed)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={agreed ? "checkmark-circle" : "ellipse-outline"}
|
||||||
|
size={20}
|
||||||
|
color={agreed ? "#5B4CFF" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
<Text style={styles.checkboxText}>
|
||||||
|
申请对方同意我查看并管理其健康档案,有数据异常预警我
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.inviteButton, (!agreed || isLoading) && styles.inviteButtonDisabled]}
|
||||||
|
disabled={!agreed || isLoading}
|
||||||
|
onPress={handleInvite}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.inviteButtonText}>立即邀请</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* QR Code Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showQRModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowQRModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>邀请家人加入</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowQRModal(false)}>
|
||||||
|
<Ionicons name="close" size={24} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isInviteLoading ? (
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
) : inviteCode ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
{/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */}
|
||||||
|
<View style={styles.inviteCodeDisplay}>
|
||||||
|
<Ionicons name="qr-code-outline" size={48} color="#5B4CFF" />
|
||||||
|
<Text style={styles.inviteCodeBig}>{inviteCode.inviteCode}</Text>
|
||||||
|
<Text style={styles.inviteCodeHint}>请让家人在 App 中输入此邀请码</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inviteCodeContainer}>
|
||||||
|
<Text style={styles.inviteCodeLabel}>邀请码</Text>
|
||||||
|
<Text style={styles.inviteCodeText}>{inviteCode.inviteCode}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.expireText}>
|
||||||
|
有效期至:{new Date(inviteCode.expiresAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.shareButton} onPress={handleShare}>
|
||||||
|
<Ionicons name="share-outline" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.shareButtonText}>分享邀请</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
headerSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
mainTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
lineHeight: 36,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitleBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
subtitleText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
marginLeft: 6,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
heroContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: 180,
|
||||||
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
houseIconPlaceholder: {
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
houseIconGradient: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: [{ rotate: '45deg' }],
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
floatingLabel: {
|
||||||
|
position: 'absolute',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
labelLeft: {
|
||||||
|
left: 0,
|
||||||
|
top: 40,
|
||||||
|
},
|
||||||
|
labelRight: {
|
||||||
|
right: 0,
|
||||||
|
top: 20,
|
||||||
|
},
|
||||||
|
floatingLabelText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginHorizontal: 4,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
},
|
||||||
|
featuresCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
featureItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
featureIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
featureTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
featureDesc: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
stepsContainer: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 30,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
stepsTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
stepsSubtitleContainer: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignSelf: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
stepsSubtitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
stepsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
stepItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
stepNumber: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
stepDesc: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4B5563',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
stepPhoneMockup: {
|
||||||
|
width: 60,
|
||||||
|
height: 100,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
padding: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
mockupScreen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
bottomArea: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
checkboxRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
checkboxText: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
inviteButton: {
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
height: 56,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
inviteButtonDisabled: {
|
||||||
|
backgroundColor: '#C4B5FD',
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
inviteButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
qrContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
minHeight: 180,
|
||||||
|
},
|
||||||
|
inviteCodeDisplay: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
inviteCodeBig: {
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
letterSpacing: 4,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
inviteCodeHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
inviteCodeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
inviteCodeLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
inviteCodeText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
|
expireText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
shareButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
343
app/health/profile.tsx
Normal file
343
app/health/profile.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { HealthProgressRing } from '@/components/health/HealthProgressRing';
|
||||||
|
import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
|
||||||
|
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
|
||||||
|
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||||
|
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import { selectHealthHistoryProgress } from '@/store/healthSlice';
|
||||||
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function HealthProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
|
// Mock user data - in a real app this would come from Redux/Context
|
||||||
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
const displayName = userProfile.name?.trim() ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
|
const avatarUrl = userProfile.avatar || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||||
|
|
||||||
|
// 从 Redux 获取健康史进度
|
||||||
|
const healthHistoryProgress = useAppSelector(selectHealthHistoryProgress);
|
||||||
|
|
||||||
|
// Mock health data
|
||||||
|
const healthData = {
|
||||||
|
bmi: userProfile.weight && userProfile.height ? (parseFloat(userProfile.weight) / Math.pow(parseFloat(userProfile.height) / 100, 2)).toFixed(1) : '--',
|
||||||
|
height: userProfile.height ? `${parseFloat(userProfile.height).toFixed(1)}` : '--',
|
||||||
|
weight: userProfile.weight ? `${parseFloat(userProfile.weight).toFixed(1)}` : '--',
|
||||||
|
waist: userProfile.waistCircumference ? `${parseFloat(userProfile.waistCircumference.toString()).toFixed(1)}` : '--',
|
||||||
|
status: '健康状况良好',
|
||||||
|
statusDesc: '请继续保持良好的生活习惯',
|
||||||
|
statusMessage: '您的健康状况不错哦~'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate Basic Info completion percentage
|
||||||
|
const basicInfoProgress = useMemo(() => {
|
||||||
|
let filledCount = 0;
|
||||||
|
const totalFields = 3; // height, weight, waist
|
||||||
|
|
||||||
|
if (userProfile.height && parseFloat(userProfile.height) > 0) filledCount++;
|
||||||
|
if (userProfile.weight && parseFloat(userProfile.weight) > 0) filledCount++;
|
||||||
|
if (userProfile.waistCircumference && parseFloat(userProfile.waistCircumference.toString()) > 0) filledCount++;
|
||||||
|
|
||||||
|
return Math.round((filledCount / totalFields) * 100);
|
||||||
|
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
|
||||||
|
|
||||||
|
const gradientColors: [string, string] =
|
||||||
|
theme === 'dark'
|
||||||
|
? ['#1f2230', '#10131e']
|
||||||
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
t('health.tabs.healthProfile.basicInfo'),
|
||||||
|
t('health.tabs.healthProfile.healthHistory'),
|
||||||
|
// t('health.tabs.healthProfile.medicalRecords'),
|
||||||
|
t('health.tabs.healthProfile.checkupRecords'),
|
||||||
|
t('health.tabs.healthProfile.medicineBox')
|
||||||
|
];
|
||||||
|
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
|
||||||
|
|
||||||
|
const handleTabPress = (index: number) => {
|
||||||
|
if (index === 4) {
|
||||||
|
// Handle Medicine Box tab specially
|
||||||
|
router.push('/medications/manage-medications');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActiveTab = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 0:
|
||||||
|
return <BasicInfoTab healthData={healthData} />;
|
||||||
|
case 1:
|
||||||
|
return <HealthHistoryTab />;
|
||||||
|
case 2:
|
||||||
|
return <MedicalRecordsTab />;
|
||||||
|
case 3:
|
||||||
|
return <CheckupRecordsTab />;
|
||||||
|
default:
|
||||||
|
return <BasicInfoTab healthData={healthData} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title={t('health.tabs.healthProfile.title')}
|
||||||
|
transparent
|
||||||
|
right={
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<TouchableOpacity style={{ marginRight: 12 }}>
|
||||||
|
<Ionicons name="settings-outline" size={22} color="#1F2937" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 60 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top Section with Avatar and Status */}
|
||||||
|
<View style={styles.topSection}>
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
<View style={styles.miniAvatarContainer}>
|
||||||
|
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
|
||||||
|
<Text style={styles.miniAvatarName}>{displayName}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.addButton}>
|
||||||
|
<Ionicons name="add" size={16} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons - Replaced with HealthProgressRing */}
|
||||||
|
<View style={styles.actionButtonsRow}>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.basicInfo')}
|
||||||
|
progress={basicInfoProgress}
|
||||||
|
gradientColors={['#9B8AFB', '#5B4CFF']}
|
||||||
|
/>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.healthHistory')}
|
||||||
|
progress={healthHistoryProgress}
|
||||||
|
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||||
|
label={healthHistoryProgress.toString()}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.medicalRecords')}
|
||||||
|
progress={0}
|
||||||
|
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||||
|
label="0"
|
||||||
|
suffix="份"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Family Invite Banner */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.inviteBanner}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||||
|
>
|
||||||
|
<View style={styles.inviteContent}>
|
||||||
|
<View style={styles.inviteIconContainer}>
|
||||||
|
<Ionicons name="home" size={18} color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.inviteText}>{t('health.tabs.healthProfile.subtitle')}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Tab/Segment Control */}
|
||||||
|
<View style={styles.segmentControl}>
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.segmentItem}
|
||||||
|
onPress={() => handleTabPress(index)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.segmentIconPlaceholder, index === activeTab && styles.segmentIconActive]}>
|
||||||
|
<Ionicons
|
||||||
|
name={tabIcons[index] as any}
|
||||||
|
size={20}
|
||||||
|
color={index === activeTab ? "#5B4CFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.segmentText, index === activeTab && styles.segmentTextActive]}>{tab}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Active Tab Content */}
|
||||||
|
{renderActiveTab()}
|
||||||
|
|
||||||
|
{/* Privacy Notice Footer */}
|
||||||
|
<View style={styles.privacyNoticeContainer}>
|
||||||
|
<View style={styles.privacyIconWrapper}>
|
||||||
|
<Ionicons name="shield-checkmark" size={16} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.privacyNoticeText}>
|
||||||
|
{t('health.tabs.healthProfile.privacyNotice')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Privacy Warning Footer - Removed as requested */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
topSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
avatarRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
miniAvatarContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingRight: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
miniAvatar: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginRight: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
miniAvatarName: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
actionButtonsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
inviteBanner: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
inviteContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inviteIconContainer: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
inviteText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1F2138',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
segmentControl: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
},
|
||||||
|
segmentItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
segmentIconPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
segmentIconActive: {
|
||||||
|
backgroundColor: '#E0E7FF',
|
||||||
|
},
|
||||||
|
segmentText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
segmentTextActive: {
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
privacyNoticeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
privacyIconWrapper: {
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
privacyNoticeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import NumberKeyboard from '@/components/NumberKeyboard';
|
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { WeightProgressBar } from '@/components/weight/WeightProgressBar';
|
||||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
@@ -39,14 +41,16 @@ export default function WeightRecordsPage() {
|
|||||||
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const themeColors = Colors[colorScheme ?? 'light'];
|
const themeColors = Colors[colorScheme ?? 'light'];
|
||||||
|
const { isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
const loadWeightHistory = useCallback(async () => {
|
const loadWeightHistory = useCallback(async () => {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
try {
|
try {
|
||||||
await dispatch(fetchWeightHistory() as any);
|
await dispatch(fetchWeightHistory() as any);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('weightRecords.loadingHistory'), error);
|
console.error(t('weightRecords.loadingHistory'), error);
|
||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [dispatch, isLoggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWeightHistory();
|
loadWeightHistory();
|
||||||
@@ -56,28 +60,36 @@ export default function WeightRecordsPage() {
|
|||||||
setInputWeight(weight.toString());
|
setInputWeight(weight.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWeight = () => {
|
const handleAddWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('current');
|
setPickerType('current');
|
||||||
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
|
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
|
||||||
initializeInput(weight);
|
initializeInput(weight);
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditInitialWeight = () => {
|
const handleEditInitialWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('initial');
|
setPickerType('initial');
|
||||||
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
|
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
|
||||||
initializeInput(parseFloat(initialWeight));
|
initializeInput(parseFloat(initialWeight));
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTargetWeight = () => {
|
const handleEditTargetWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('target');
|
setPickerType('target');
|
||||||
const targetWeight = userProfile?.targetWeight || '60.0';
|
const targetWeight = userProfile?.targetWeight || '60.0';
|
||||||
initializeInput(parseFloat(targetWeight));
|
initializeInput(parseFloat(targetWeight));
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditWeightRecord = (record: WeightHistoryItem) => {
|
const handleEditWeightRecord = async (record: WeightHistoryItem) => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('edit');
|
setPickerType('edit');
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
initializeInput(parseFloat(record.weight));
|
initializeInput(parseFloat(record.weight));
|
||||||
@@ -85,6 +97,8 @@ export default function WeightRecordsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWeightRecord = async (id: string) => {
|
const handleDeleteWeightRecord = async (id: string) => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await dispatch(deleteWeightRecord(id) as any);
|
await dispatch(deleteWeightRecord(id) as any);
|
||||||
await loadWeightHistory();
|
await loadWeightHistory();
|
||||||
@@ -180,6 +194,12 @@ export default function WeightRecordsPage() {
|
|||||||
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
|
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
|
||||||
const totalWeightLoss = initialWeight - currentWeight;
|
const totalWeightLoss = initialWeight - currentWeight;
|
||||||
|
|
||||||
|
// 计算减重进度
|
||||||
|
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||||
|
const totalToLose = initialWeight - targetWeight;
|
||||||
|
const actualLost = initialWeight - currentWeight;
|
||||||
|
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 背景 */}
|
{/* 背景 */}
|
||||||
@@ -295,6 +315,19 @@ export default function WeightRecordsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 减重进度条 - 仅在设置了目标体重时显示 */}
|
||||||
|
{hasTargetWeight && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<WeightProgressBar
|
||||||
|
progress={weightProgress}
|
||||||
|
currentWeight={currentWeight}
|
||||||
|
targetWeight={targetWeight}
|
||||||
|
initialWeight={initialWeight}
|
||||||
|
showTopBorder={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Monthly Records */}
|
{/* Monthly Records */}
|
||||||
{Object.keys(groupedHistory).length > 0 ? (
|
{Object.keys(groupedHistory).length > 0 ? (
|
||||||
<View style={styles.historySection}>
|
<View style={styles.historySection}>
|
||||||
@@ -628,6 +661,20 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Progress Container
|
||||||
|
progressContainer: {
|
||||||
|
marginHorizontal: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: 'rgba(30, 41, 59, 0.06)',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
|
||||||
// History Section
|
// History Section
|
||||||
historySection: {
|
historySection: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { fetchHRVSamples, HRVData } from '@/utils/health';
|
||||||
|
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
|
|||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StressStats {
|
||||||
|
percentage: number;
|
||||||
|
count: number;
|
||||||
|
range: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryData {
|
||||||
|
goodEvents: StressStats;
|
||||||
|
energetic: StressStats;
|
||||||
|
stressed: StressStats;
|
||||||
|
totalSamples: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [historyData, setHistoryData] = useState<HistoryData>({
|
||||||
|
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
|
||||||
|
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
|
||||||
|
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
|
||||||
|
totalSamples: 0
|
||||||
|
});
|
||||||
|
|
||||||
// 模拟30天HRV数据
|
// 当前压力状态
|
||||||
const hrvData = {
|
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||||
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
|
const stressInfo = getStressLevelInfo(stressIndex);
|
||||||
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
|
|
||||||
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
loadHistoryData();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const loadHistoryData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = dayjs().subtract(30, 'day').toDate();
|
||||||
|
|
||||||
|
const samples = await fetchHRVSamples(startDate, endDate);
|
||||||
|
processHistoryData(samples);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load HRV history:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processHistoryData = (samples: HRVData[]) => {
|
||||||
|
if (!samples.length) return;
|
||||||
|
|
||||||
|
let goodCount = 0;
|
||||||
|
let energeticCount = 0;
|
||||||
|
let stressedCount = 0;
|
||||||
|
|
||||||
|
samples.forEach(sample => {
|
||||||
|
const val = sample.value;
|
||||||
|
if (val > 75) {
|
||||||
|
goodCount++;
|
||||||
|
} else if (val >= 40) {
|
||||||
|
energeticCount++;
|
||||||
|
} else {
|
||||||
|
stressedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = samples.length;
|
||||||
|
|
||||||
|
setHistoryData({
|
||||||
|
goodEvents: {
|
||||||
|
percentage: Math.round((goodCount / total) * 100),
|
||||||
|
count: goodCount,
|
||||||
|
range: '>75毫秒'
|
||||||
|
},
|
||||||
|
energetic: {
|
||||||
|
percentage: Math.round((energeticCount / total) * 100),
|
||||||
|
count: energeticCount,
|
||||||
|
range: '40-75毫秒'
|
||||||
|
},
|
||||||
|
stressed: {
|
||||||
|
percentage: Math.round((stressedCount / total) * 100),
|
||||||
|
count: stressedCount,
|
||||||
|
range: '<40毫秒'
|
||||||
|
},
|
||||||
|
totalSamples: total
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'low': return '#10B981';
|
||||||
|
case 'moderate': return '#3B82F6';
|
||||||
|
case 'high': return '#F59E0B';
|
||||||
|
default: return colors.text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
|||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 0, y: 1 }}
|
||||||
>
|
>
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
{/* 标题 */}
|
{/* 标题区域 */}
|
||||||
<Text style={styles.title}>压力情况分析</Text>
|
<Text style={styles.title}>压力分析</Text>
|
||||||
|
|
||||||
|
{/* 当前状态卡片 */}
|
||||||
|
<View style={styles.currentStatusCard}>
|
||||||
|
<View style={styles.statusHeader}>
|
||||||
|
<Text style={styles.statusLabel}>当前状态</Text>
|
||||||
|
<Text style={styles.updateTime}>更新于 {dayjs(updateTime).format('HH:mm')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusValueContainer}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
|
||||||
|
{stressInfo.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.hrvValueBox}>
|
||||||
|
<Text style={styles.hrvValueLabel}>HRV</Text>
|
||||||
|
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
{/* 最近30天HRV情况 */}
|
{/* 最近30天HRV情况 */}
|
||||||
<Text style={styles.sectionTitle}>最近30天HRV情况</Text>
|
<Text style={styles.sectionTitle}>最近30天压力分布</Text>
|
||||||
|
|
||||||
{/* 彩色横条图 */}
|
{loading ? (
|
||||||
<View style={styles.chartContainer}>
|
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
|
||||||
<View style={styles.colorBar}>
|
) : (
|
||||||
<LinearGradient
|
<>
|
||||||
colors={['#F59E0B', '#3B82F6', '#10B981']}
|
{/* 彩色横条图 */}
|
||||||
start={{ x: 0, y: 0 }}
|
<View style={styles.chartContainer}>
|
||||||
end={{ x: 1, y: 0 }}
|
<View style={styles.colorBar}>
|
||||||
style={styles.gradientBar}
|
{historyData.totalSamples > 0 ? (
|
||||||
/>
|
<View style={styles.progressBarContainer}>
|
||||||
</View>
|
{historyData.stressed.percentage > 0 && (
|
||||||
<View style={styles.legend}>
|
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
|
||||||
<View style={styles.legendItem}>
|
)}
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
{historyData.energetic.percentage > 0 && (
|
||||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
|
||||||
</View>
|
)}
|
||||||
<View style={styles.legendItem}>
|
{historyData.goodEvents.percentage > 0 && (
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
|
||||||
<Text style={styles.legendText}>活力满满</Text>
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.legendItem}>
|
) : (
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
|
||||||
<Text style={styles.legendText}>好事发生</Text>
|
)}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 数据统计卡片 */}
|
|
||||||
<View style={styles.statsCard}>
|
|
||||||
{/* 好事发生 & 活力满满 */}
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
|
||||||
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
|
|
||||||
<View style={styles.statDetails}>
|
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.goodEvents.range}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.goodEvents.count}次</Text>
|
|
||||||
</View>
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
<View style={styles.statItem}>
|
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||||
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
|
</View>
|
||||||
<View style={styles.statDetails}>
|
<View style={styles.legendItem}>
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.energetic.range}</Text>
|
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||||
|
<Text style={styles.legendText}>活力满满</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||||
|
<Text style={styles.legendText}>好事发生</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.energetic.count}次</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 鸭梨山大 */}
|
{/* 数据统计卡片 */}
|
||||||
<View style={styles.statItem}>
|
<View style={styles.statsCard}>
|
||||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
{/* 好事发生 & 活力满满 */}
|
||||||
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statDetails}>
|
<View style={styles.statItem}>
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.stressed.range}</Text>
|
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
|
||||||
|
HRV {historyData.goodEvents.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.goodEvents.count}次</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
|
||||||
|
HRV {historyData.energetic.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.energetic.count}次</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 鸭梨山大 */}
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
|
||||||
|
HRV {historyData.stressed.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.stressed.count}次</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.stressed.count}次</Text>
|
</>
|
||||||
</View>
|
)}
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部继续按钮 */}
|
{/* 底部继续按钮 */}
|
||||||
<View style={styles.bottomContainer}>
|
<View style={styles.bottomContainer}>
|
||||||
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
|
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
|
||||||
<View style={styles.buttonBackground}>
|
{isLiquidGlassAvailable() ? (
|
||||||
<Text style={styles.buttonText}>继续</Text>
|
<GlassView
|
||||||
</View>
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(139, 92, 246, 0.85)"
|
||||||
|
isInteractive={true}
|
||||||
|
style={styles.glassButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>继续</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#8B5CF6', '#7C3AED']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>继续</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.homeIndicator} />
|
<View style={styles.homeIndicator} />
|
||||||
</View>
|
</View>
|
||||||
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 20,
|
marginTop: 24,
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
|
currentStatusCard: {
|
||||||
sectionTitle: {
|
backgroundColor: '#FFFFFF',
|
||||||
fontSize: 22,
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 32,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
statusHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
updateTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
statusValueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statusDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
hrvValueBox: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
hrvValueLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
hrvValue: {
|
||||||
|
fontSize: 32,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
hrvUnit: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111827',
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
chartContainer: {
|
chartContainer: {
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
progressSegment: {
|
||||||
|
height: '100%',
|
||||||
},
|
},
|
||||||
gradientBar: {
|
gradientBar: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
legendDot: {
|
legendDot: {
|
||||||
width: 12,
|
width: 10,
|
||||||
height: 12,
|
height: 10,
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
marginRight: 6,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
legendText: {
|
legendText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
color: '#374151',
|
color: '#4B5563',
|
||||||
},
|
},
|
||||||
statsCard: {
|
statsCard: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
padding: 20,
|
padding: 24,
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
width: 0,
|
shadowOpacity: 0.04,
|
||||||
height: 2,
|
shadowRadius: 12,
|
||||||
},
|
elevation: 3,
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
},
|
||||||
statsRow: {
|
statsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 20,
|
gap: 24,
|
||||||
marginBottom: 24,
|
marginBottom: 32,
|
||||||
},
|
},
|
||||||
statItem: {
|
statItem: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
statTitle: {
|
statTitle: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '600',
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
statPercentage: {
|
statPercentage: {
|
||||||
fontSize: 36,
|
fontSize: 32,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
statDetails: {
|
statDetails: {
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
statRange: {
|
statRange: {
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#DC2626',
|
|
||||||
backgroundColor: '#FEE2E2',
|
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 3,
|
paddingVertical: 4,
|
||||||
borderRadius: 10,
|
borderRadius: 6,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
statCount: {
|
statCount: {
|
||||||
fontSize: 16,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
color: '#6B7280',
|
color: '#6B7280',
|
||||||
},
|
},
|
||||||
bottomContainer: {
|
bottomContainer: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 34,
|
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
continueButton: {
|
continueButton: {
|
||||||
borderRadius: 25,
|
borderRadius: 28,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
|
shadowColor: '#8B5CF6',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
glassButton: {
|
||||||
|
paddingVertical: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 28,
|
||||||
},
|
},
|
||||||
buttonGradient: {
|
buttonGradient: {
|
||||||
paddingVertical: 18,
|
paddingVertical: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
flexDirection: 'row',
|
||||||
buttonBackground: {
|
|
||||||
backgroundColor: Colors.light.accentGreen, // 应用主色调
|
|
||||||
paddingVertical: 18,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126', // 主色调上的文字颜色
|
color: '#FFFFFF',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
homeIndicator: {
|
homeIndicator: {
|
||||||
width: 134,
|
width: 134,
|
||||||
height: 5,
|
height: 5,
|
||||||
backgroundColor: '#000',
|
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [hrvValue, setHrvValue] = useState(0)
|
const [hrvValue, setHrvValue] = useState(0)
|
||||||
|
const [updateTime, setUpdateTime] = useState<Date>(new Date())
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
|
|
||||||
if (result.hrvData) {
|
if (result.hrvData) {
|
||||||
setHrvValue(Math.round(result.hrvData.value));
|
setHrvValue(Math.round(result.hrvData.value));
|
||||||
|
if (result.hrvData.recordedAt) {
|
||||||
|
setUpdateTime(new Date(result.hrvData.recordedAt));
|
||||||
|
}
|
||||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.log('StressMeter: No HRV data obtained');
|
console.log('StressMeter: No HRV data obtained');
|
||||||
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
{/* 渐变背景进度条 */}
|
{/* 渐变背景进度条 */}
|
||||||
<View style={[styles.progressBar, { width: '100%' }]}>
|
<View style={[styles.progressBar, { width: '100%' }]}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#EF4444', '#FCD34D', '#10B981']}
|
colors={['#10B981', '#FCD34D', '#EF4444']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.gradientBar}
|
style={styles.gradientBar}
|
||||||
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
visible={showStressModal}
|
visible={showStressModal}
|
||||||
onClose={() => setShowStressModal(false)}
|
onClose={() => setShowStressModal(false)}
|
||||||
hrvValue={hrvValue}
|
hrvValue={hrvValue}
|
||||||
updateTime={new Date()}
|
updateTime={updateTime}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
132
components/health/HealthProgressRing.tsx
Normal file
132
components/health/HealthProgressRing.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
|
||||||
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
|
|
||||||
|
export type HealthProgressRingProps = {
|
||||||
|
progress: number; // 0-100
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
gradientColors?: string[];
|
||||||
|
label?: string;
|
||||||
|
suffix?: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HealthProgressRing({
|
||||||
|
progress,
|
||||||
|
size = 80,
|
||||||
|
strokeWidth = 8,
|
||||||
|
gradientColors = ['#5B4CFF', '#9B8AFB'],
|
||||||
|
label,
|
||||||
|
suffix = '%',
|
||||||
|
title,
|
||||||
|
}: HealthProgressRingProps) {
|
||||||
|
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const center = size / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(animatedProgress, {
|
||||||
|
toValue: progress,
|
||||||
|
duration: 1000,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const strokeDashoffset = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 100],
|
||||||
|
outputRange: [circumference, 0],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Svg width={size} height={size}>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
|
||||||
|
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{/* Background Circle */}
|
||||||
|
<Circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke="#F3F4F6"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Progress Circle */}
|
||||||
|
<AnimatedCircle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke={`url(#${gradientId})`}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${center} ${center})`}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
|
||||||
|
<View style={styles.centerContent}>
|
||||||
|
<View style={styles.valueContainer}>
|
||||||
|
<Text style={styles.valueText}>{label ?? progress}</Text>
|
||||||
|
<Text style={styles.suffixText}>{suffix}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.titleText}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
|
position: 'absolute',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
valueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
suffixText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: 1,
|
||||||
|
marginBottom: 3,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#4B5563', // gray-600
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
161
components/health/tabs/BasicInfoTab.tsx
Normal file
161
components/health/tabs/BasicInfoTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
type BasicInfoTabProps = {
|
||||||
|
healthData: {
|
||||||
|
bmi: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
waist: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleHeightWeightPress = () => {
|
||||||
|
router.push(ROUTES.PROFILE_EDIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWaistPress = () => {
|
||||||
|
router.push('/circumference-detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
|
||||||
|
<View style={styles.metricsGrid}>
|
||||||
|
{/* BMI - Highlighted */}
|
||||||
|
<View style={styles.metricItemMain}>
|
||||||
|
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
|
||||||
|
<Text style={styles.metricValueMain}>
|
||||||
|
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Height - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleHeightWeightPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.height}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Weight - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleHeightWeightPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.weight}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Waist - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleWaistPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.waist}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 16,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
metricsGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
metricItemMain: {
|
||||||
|
flex: 1.5,
|
||||||
|
backgroundColor: '#F5F3FF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
metricHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 2,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
metricLabelMain: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
metricValueMain: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
metricItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
metricHeaderSmall: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#1F2937',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export function CheckupRecordsTab() {
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
|
||||||
|
<Text style={styles.emptyText}>暂无体检记录</Text>
|
||||||
|
<Text style={styles.emptySubtext}>记录并追踪您的体检数据变化</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 40,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { HealthHistoryCategory } from '@/services/healthProfile';
|
||||||
|
import {
|
||||||
|
HistoryItemDetail,
|
||||||
|
fetchHealthHistory,
|
||||||
|
saveHealthHistoryCategory,
|
||||||
|
selectHealthLoading,
|
||||||
|
selectHistoryData,
|
||||||
|
updateHistoryData,
|
||||||
|
} from '@/store/healthSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||||
|
import { palette } from '../../../constants/Colors';
|
||||||
|
|
||||||
|
// Translation Keys for Recommendations
|
||||||
|
const RECOMMENDATION_KEYS: Record<string, string[]> = {
|
||||||
|
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
|
||||||
|
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
|
||||||
|
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
|
||||||
|
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HistoryItemProps {
|
||||||
|
title: string;
|
||||||
|
categoryKey: string;
|
||||||
|
data: {
|
||||||
|
hasHistory: boolean | null;
|
||||||
|
items: HistoryItemDetail[];
|
||||||
|
};
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const translateItemName = (name: string) => {
|
||||||
|
const keys = RECOMMENDATION_KEYS[categoryKey];
|
||||||
|
if (keys && keys.includes(name)) {
|
||||||
|
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasItems = data.hasHistory === true && data.items.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Header Row */}
|
||||||
|
<View style={styles.itemHeader}>
|
||||||
|
<View style={styles.itemLeft}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[palette.purple[400], palette.purple[600]]}
|
||||||
|
style={styles.indicator}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.itemTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!hasItems && (
|
||||||
|
<Text style={[
|
||||||
|
styles.itemStatus,
|
||||||
|
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
|
||||||
|
]}>
|
||||||
|
{data.hasHistory === null
|
||||||
|
? t('health.tabs.healthProfile.history.pending')
|
||||||
|
: data.hasHistory === false
|
||||||
|
? t('health.tabs.healthProfile.history.modal.none')
|
||||||
|
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List of Items */}
|
||||||
|
{hasItems && (
|
||||||
|
<View style={styles.subListContainer}>
|
||||||
|
{data.items.map(item => (
|
||||||
|
<View key={item.id} style={styles.subItemRow}>
|
||||||
|
<View style={styles.subItemDot} />
|
||||||
|
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
|
||||||
|
{item.date && (
|
||||||
|
<Text style={styles.subItemDate}>
|
||||||
|
{dayjs(item.date).format('YYYY-MM-DD')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthHistoryTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// 从 Redux store 获取健康史数据和加载状态
|
||||||
|
const historyData = useAppSelector(selectHistoryData);
|
||||||
|
const isLoading = useAppSelector(selectHealthLoading);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [currentType, setCurrentType] = useState<string | null>(null);
|
||||||
|
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
|
||||||
|
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Date Picker State
|
||||||
|
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||||
|
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 初始化时从服务端获取健康史数据
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchHealthHistory());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const historyItems = [
|
||||||
|
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to translate item (try to find key, fallback to item itself)
|
||||||
|
const translateItem = (type: string, item: string) => {
|
||||||
|
// Check if item is a predefined key
|
||||||
|
const keys = RECOMMENDATION_KEYS[type];
|
||||||
|
if (keys && keys.includes(item)) {
|
||||||
|
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
|
||||||
|
}
|
||||||
|
// Fallback for manual input
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open Modal
|
||||||
|
const handleItemPress = (key: string) => {
|
||||||
|
setCurrentType(key);
|
||||||
|
const currentData = historyData[key];
|
||||||
|
setTempHasHistory(currentData.hasHistory);
|
||||||
|
// Deep copy items to avoid reference issues
|
||||||
|
setTempItems(currentData.items.map(item => ({ ...item })));
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close Modal
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setCurrentType(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save Data
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (currentType) {
|
||||||
|
// Filter out empty items
|
||||||
|
const validItems = tempItems.filter(item => item.name.trim() !== '');
|
||||||
|
|
||||||
|
// If "No" history is selected, clear items
|
||||||
|
const finalItems = tempHasHistory === false ? [] : validItems;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先乐观更新本地状态
|
||||||
|
dispatch(updateHistoryData({
|
||||||
|
type: currentType,
|
||||||
|
data: {
|
||||||
|
hasHistory: tempHasHistory,
|
||||||
|
items: finalItems,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 同步到服务端
|
||||||
|
await dispatch(saveHealthHistoryCategory({
|
||||||
|
category: currentType as HealthHistoryCategory,
|
||||||
|
data: {
|
||||||
|
hasHistory: tempHasHistory ?? false,
|
||||||
|
items: finalItems.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
|
||||||
|
isRecommendation: item.isRecommendation,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
|
||||||
|
Alert.alert(
|
||||||
|
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
|
||||||
|
error?.message || '请稍后重试',
|
||||||
|
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Item (Manual or Recommendation)
|
||||||
|
const addItem = (name: string = '', isRecommendation: boolean = false) => {
|
||||||
|
// Avoid duplicates for recommendations if already exists
|
||||||
|
if (isRecommendation && tempItems.some(item => item.name === name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem: HistoryItemDetail = {
|
||||||
|
id: Date.now().toString() + Math.random().toString(),
|
||||||
|
name,
|
||||||
|
isRecommendation
|
||||||
|
};
|
||||||
|
setTempItems([...tempItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove Item
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
setTempItems(tempItems.filter(item => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Item Name
|
||||||
|
const updateItemName = (id: string, text: string) => {
|
||||||
|
setTempItems(tempItems.map(item =>
|
||||||
|
item.id === id ? { ...item, name: text } : item
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date Picker Handlers
|
||||||
|
const showDatePicker = (id: string) => {
|
||||||
|
setCurrentEditingId(id);
|
||||||
|
setDatePickerVisibility(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideDatePicker = () => {
|
||||||
|
setDatePickerVisibility(false);
|
||||||
|
setCurrentEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDate = (date: Date) => {
|
||||||
|
if (currentEditingId) {
|
||||||
|
setTempItems(tempItems.map(item =>
|
||||||
|
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
|
||||||
|
));
|
||||||
|
}
|
||||||
|
hideDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Glow effect background */}
|
||||||
|
<View style={styles.glowContainer}>
|
||||||
|
<View style={styles.glow} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
|
||||||
|
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<View style={styles.list}>
|
||||||
|
{historyItems.map((item) => (
|
||||||
|
<HistoryItem
|
||||||
|
key={item.key}
|
||||||
|
title={item.title}
|
||||||
|
categoryKey={item.key}
|
||||||
|
data={historyData[item.key]}
|
||||||
|
onPress={() => handleItemPress(item.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
visible={modalVisible}
|
||||||
|
onRequestClose={handleCloseModal}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
|
||||||
|
<Ionicons name="close" size={24} color={palette.gray[400]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Question: Do you have history? */}
|
||||||
|
<Text style={styles.questionText}>
|
||||||
|
{t('health.tabs.healthProfile.history.modal.question', {
|
||||||
|
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.radioGroup}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.radioButton,
|
||||||
|
tempHasHistory === true && styles.radioButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setTempHasHistory(true)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.radioText,
|
||||||
|
tempHasHistory === true && styles.radioTextActive
|
||||||
|
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.radioButton,
|
||||||
|
tempHasHistory === false && styles.radioButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setTempHasHistory(false)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.radioText,
|
||||||
|
tempHasHistory === false && styles.radioTextActive
|
||||||
|
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Conditional Content */}
|
||||||
|
{tempHasHistory === true && currentType && (
|
||||||
|
<View style={styles.detailsContainer}>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{RECOMMENDATION_KEYS[currentType] && (
|
||||||
|
<View style={styles.recommendationContainer}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
|
||||||
|
<View style={styles.tagsContainer}>
|
||||||
|
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.tag}
|
||||||
|
onPress={() => addItem(tagKey, true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.tagText}>
|
||||||
|
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History List Items */}
|
||||||
|
<View style={styles.listContainer}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
|
||||||
|
|
||||||
|
{tempItems.map((item) => (
|
||||||
|
<View key={item.id} style={styles.listItemCard}>
|
||||||
|
<View style={styles.listItemHeader}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.listItemNameInput}
|
||||||
|
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
|
||||||
|
placeholderTextColor={palette.gray[300]}
|
||||||
|
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
|
||||||
|
onChangeText={(text) => updateItemName(item.id, text)}
|
||||||
|
editable={!item.isRecommendation}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.datePickerTrigger}
|
||||||
|
onPress={() => showDatePicker(item.id)}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
|
||||||
|
<Text style={[
|
||||||
|
styles.dateText,
|
||||||
|
!item.date && styles.placeholderText
|
||||||
|
]}>
|
||||||
|
{item.date
|
||||||
|
? dayjs(item.date).format('YYYY-MM-DD')
|
||||||
|
: t('health.tabs.healthProfile.history.modal.selectDate')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
|
||||||
|
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
|
||||||
|
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View style={styles.modalFooter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
|
||||||
|
style={styles.saveButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DateTimePickerModal
|
||||||
|
isVisible={isDatePickerVisible}
|
||||||
|
mode="date"
|
||||||
|
onConfirm={handleConfirmDate}
|
||||||
|
onCancel={hideDatePicker}
|
||||||
|
maximumDate={new Date()} // Cannot select future date for history
|
||||||
|
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
|
||||||
|
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 16,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
glowContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
width: '90%',
|
||||||
|
height: '90%',
|
||||||
|
backgroundColor: palette.purple[200],
|
||||||
|
opacity: 0.3,
|
||||||
|
borderRadius: 40,
|
||||||
|
transform: [{ scale: 1.05 }],
|
||||||
|
shadowColor: palette.purple[500],
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: palette.purple[100],
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#F5F3FF',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
itemContainerWithList: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
itemHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
itemLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
width: 4,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[700],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
itemStatus: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[300],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
textAlign: 'right',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
itemStatusActive: {
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
subListContainer: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
|
||||||
|
},
|
||||||
|
subItemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
subItemDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: palette.purple[300],
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
subItemName: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: palette.gray[800],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
subItemDate: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: palette.gray[400],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
// Modal Styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
maxHeight: '85%', // Increased height
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[700],
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
radioGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
radioButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[200],
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
radioButtonActive: {
|
||||||
|
backgroundColor: palette.purple[50],
|
||||||
|
borderColor: palette.purple[500],
|
||||||
|
},
|
||||||
|
radioText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
radioTextActive: {
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
detailsContainer: {
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[500],
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
recommendationContainer: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
tagsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F5F7FA',
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[600],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
listItemCard: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[100],
|
||||||
|
},
|
||||||
|
listItemHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
listItemNameInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
datePickerTrigger: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[200],
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
color: palette.gray[400],
|
||||||
|
},
|
||||||
|
addItemButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.purple[200],
|
||||||
|
borderRadius: 12,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
backgroundColor: palette.purple[25],
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
addItemText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
modalFooter: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: palette.purple[500],
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
saveButtonDisabled: {
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
saveButtonGradient: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
49
components/health/tabs/MedicalRecordsTab.tsx
Normal file
49
components/health/tabs/MedicalRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export function MedicalRecordsTab() {
|
||||||
|
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>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 40,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -20,18 +21,26 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Svg, { Circle, Path } from 'react-native-svg';
|
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
|
||||||
|
import { WeightProgressBar } from './WeightProgressBar';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
|
const CARD_WIDTH = screenWidth - 40;
|
||||||
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
|
const CHART_WIDTH = CARD_WIDTH - 36;
|
||||||
const CHART_HEIGHT = 60;
|
const CHART_HEIGHT = 70;
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
const THEME_PRIMARY = '#4F5BD5';
|
||||||
|
const THEME_SECONDARY = '#6B6CFF';
|
||||||
|
const THEME_SUCCESS = '#22C55E';
|
||||||
|
const THEME_TEXT_PRIMARY = '#1c1f3a';
|
||||||
|
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||||
|
|
||||||
export function WeightHistoryCard() {
|
export function WeightHistoryCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
|
|
||||||
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
|
|||||||
|
|
||||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadWeightHistory();
|
loadWeightHistory();
|
||||||
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToCoach = () => {
|
// 点击添加按钮 - 需要登录
|
||||||
|
const handleAddWeight = () => {
|
||||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
|
|||||||
setShowBMIModal(false);
|
setShowBMIModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 点击卡片 - 直接跳转,不需要登录
|
||||||
const navigateToWeightRecords = () => {
|
const navigateToWeightRecords = () => {
|
||||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
router.push(ROUTES.WEIGHT_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Process weight history data
|
// Process weight history data
|
||||||
const sortedHistory = [...weightHistory]
|
const sortedHistory = [...weightHistory]
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
.slice(-7); // Show only the last 7 records
|
.slice(-7);
|
||||||
|
|
||||||
// return (
|
// 是否有数据
|
||||||
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
const hasData = sortedHistory.length > 0;
|
||||||
// <View style={styles.cardHeader}>
|
|
||||||
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
|
||||||
// </View>
|
|
||||||
|
|
||||||
// <View style={styles.emptyContent}>
|
// 计算减重进度
|
||||||
// <Text style={styles.emptyDescription}>
|
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
|
||||||
// No weight records yet, click the button below to start recording
|
const initialWeight = userProfile?.initialWeight
|
||||||
// </Text>
|
? parseFloat(userProfile.initialWeight)
|
||||||
// <TouchableOpacity
|
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
|
||||||
// style={styles.recordButton}
|
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
|
||||||
// onPress={(e) => {
|
|
||||||
// e.stopPropagation();
|
// 计算进度百分比
|
||||||
// navigateToCoach();
|
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||||
// }}
|
const totalToLose = initialWeight - targetWeight;
|
||||||
// activeOpacity={0.8}
|
const actualLost = initialWeight - currentWeight;
|
||||||
// >
|
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
|
||||||
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// </View>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Generate chart data
|
// Generate chart data
|
||||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
|
||||||
const minWeight = Math.min(...weights);
|
const minWeight = hasData ? Math.min(...weights) : 0;
|
||||||
const maxWeight = Math.max(...weights);
|
const maxWeight = hasData ? Math.max(...weights) : 0;
|
||||||
const weightRange = maxWeight - minWeight || 1;
|
const weightRange = maxWeight - minWeight || 1;
|
||||||
|
|
||||||
const points = sortedHistory.map((item, index) => {
|
const points = hasData ? sortedHistory.map((item, index) => {
|
||||||
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
||||||
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||||||
// Reduce top margin, compress whitespace
|
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
|
||||||
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
|
|
||||||
return { x, y, weight: item.weight, date: item.createdAt };
|
return { x, y, weight: item.weight, date: item.createdAt };
|
||||||
});
|
}) : [];
|
||||||
|
|
||||||
// Generate path
|
// 生成平滑曲线路径(使用贝塞尔曲线)
|
||||||
const pathData = points.map((point, index) => {
|
const generateSmoothPath = (pts: typeof points) => {
|
||||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
if (pts.length === 0) return '';
|
||||||
return `L ${point.x} ${point.y}`;
|
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||||
}).join(' ');
|
|
||||||
|
|
||||||
// If there's only one data point, display as a horizontal line
|
let path = `M ${pts[0].x} ${pts[0].y}`;
|
||||||
const singlePointPath = points.length === 1 ?
|
|
||||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
pathData;
|
const p0 = pts[Math.max(0, i - 1)];
|
||||||
|
const p1 = pts[i];
|
||||||
|
const p2 = pts[i + 1];
|
||||||
|
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||||
|
|
||||||
|
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||||
|
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||||
|
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||||
|
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||||
|
|
||||||
|
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const smoothPath = generateSmoothPath(points);
|
||||||
|
const singlePointPath = points.length === 1
|
||||||
|
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
|
||||||
|
: smoothPath;
|
||||||
|
|
||||||
|
// 空状态下的占位曲线路径(水平虚线效果)
|
||||||
|
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Image
|
<View style={styles.iconContainer}>
|
||||||
source={require('@/assets/images/icons/icon-weight.png')}
|
<Image
|
||||||
style={styles.iconSquare}
|
source={require('@/assets/images/icons/icon-weight.png')}
|
||||||
/>
|
style={styles.iconSquare}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||||
{isLgAvaliable ? (
|
{isLgAvaliable ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToCoach();
|
handleAddWeight();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<GlassView style={styles.addButtonGlass}>
|
<GlassView style={styles.addButtonGlass}>
|
||||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||||
</GlassView>
|
</GlassView>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
|
|||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToCoach();
|
handleAddWeight();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Default chart display */}
|
{/* 当前体重显示 */}
|
||||||
{sortedHistory.length > 0 && (
|
<View style={styles.currentWeightSection}>
|
||||||
<View style={styles.chartContainer}>
|
<View style={styles.weightValueContainer}>
|
||||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
|
||||||
{/* Background grid lines */}
|
<Text style={styles.weightUnit}>kg</Text>
|
||||||
|
</View>
|
||||||
|
{sortedHistory.length > 1 && (
|
||||||
|
<View style={[
|
||||||
|
styles.changeTag,
|
||||||
|
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
|
||||||
|
]}>
|
||||||
|
<Ionicons
|
||||||
|
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
|
||||||
|
size={12}
|
||||||
|
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.changeText,
|
||||||
|
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
|
||||||
|
]}>
|
||||||
|
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* More abstract line - reduce line width and display details */}
|
{/* 图表显示 */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
|
||||||
|
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
{/* 平滑曲线 */}
|
||||||
|
<Path
|
||||||
|
d={singlePointPath}
|
||||||
|
stroke="url(#lineGradient)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据点 */}
|
||||||
|
{points.map((point, index) => {
|
||||||
|
const isLastPoint = index === points.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{/* 外圈光晕 */}
|
||||||
|
{isLastPoint && (
|
||||||
|
<Circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={8}
|
||||||
|
fill={THEME_PRIMARY}
|
||||||
|
opacity={0.15}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 数据点 */}
|
||||||
|
<Circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={isLastPoint ? 4 : 2.5}
|
||||||
|
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
|
||||||
|
stroke={isLastPoint ? '#ffffff' : 'none'}
|
||||||
|
strokeWidth={isLastPoint ? 2 : 0}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 空状态 - 虚线占位 */
|
||||||
<Path
|
<Path
|
||||||
d={singlePointPath}
|
d={emptyLinePath}
|
||||||
stroke={Colors.light.accentGreen}
|
stroke="#E8EAF0"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeDasharray="8,6"
|
||||||
opacity={0.8}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
|
||||||
{/* Simplified data points - smaller and more refined */}
|
{/* 图表信息 */}
|
||||||
{points.map((point, index) => {
|
<View style={styles.chartInfo}>
|
||||||
const isLastPoint = index === points.length - 1;
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
|
||||||
return (
|
</View>
|
||||||
<React.Fragment key={index}>
|
<View style={styles.infoItem}>
|
||||||
<Circle
|
<Text style={styles.infoLabel}>
|
||||||
cx={point.x}
|
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
|
||||||
cy={point.y}
|
</Text>
|
||||||
r={isLastPoint ? 3 : 2}
|
|
||||||
fill={Colors.light.accentGreen}
|
|
||||||
opacity={0.9}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
|
|
||||||
</Svg>
|
|
||||||
|
|
||||||
{/* Concise chart information */}
|
|
||||||
<View style={styles.chartInfo}>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>
|
|
||||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
|
{/* 减重进度条 - 始终显示 */}
|
||||||
|
<WeightProgressBar
|
||||||
|
progress={weightProgress}
|
||||||
|
currentWeight={currentWeight}
|
||||||
|
targetWeight={targetWeight}
|
||||||
|
initialWeight={initialWeight}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* BMI information modal */}
|
{/* BMI information modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -323,32 +401,38 @@ export function WeightHistoryCard() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 22,
|
borderRadius: 24,
|
||||||
padding: 16,
|
padding: 18,
|
||||||
shadowColor: '#000',
|
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 8,
|
shadowRadius: 12,
|
||||||
elevation: 3,
|
elevation: 4,
|
||||||
marginTop: 16
|
marginTop: 16,
|
||||||
},
|
},
|
||||||
cardHeader: {
|
cardHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
iconSquare: {
|
iconContainer: {
|
||||||
width: 14,
|
width: 28,
|
||||||
height: 14,
|
height: 28,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 4,
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
iconSquare: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
tintColor: THEME_PRIMARY,
|
||||||
},
|
},
|
||||||
cardTitle: {
|
cardTitle: {
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
headerButtons: {
|
headerButtons: {
|
||||||
@@ -364,19 +448,56 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
},
|
},
|
||||||
addButtonGlass: {
|
addButtonGlass: {
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(147, 112, 219, 0.3)',
|
backgroundColor: 'rgba(79, 91, 213, 0.15)',
|
||||||
|
},
|
||||||
|
currentWeightSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
weightValueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
},
|
||||||
|
weightValue: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: THEME_TEXT_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
weightUnit: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
changeTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
changeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -384,12 +505,12 @@ const styles = StyleSheet.create({
|
|||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
emptyDescription: {
|
emptyDescription: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#687076',
|
color: THEME_TEXT_SECONDARY,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
@@ -397,14 +518,14 @@ const styles = StyleSheet.create({
|
|||||||
recordButton: {
|
recordButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: Colors.light.accentGreen,
|
backgroundColor: THEME_PRIMARY,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
recordButtonText: {
|
recordButtonText: {
|
||||||
color: '#192126',
|
color: '#FFFFFF',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
@@ -418,20 +539,25 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
marginTop: -14,
|
||||||
},
|
},
|
||||||
infoItem: {
|
infoItem: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.06)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
infoLabel: {
|
infoLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#687076',
|
color: THEME_TEXT_SECONDARY,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
infoValue: {
|
infoValue: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
},
|
},
|
||||||
|
|
||||||
// BMI modal styles
|
// BMI modal styles
|
||||||
@@ -556,7 +682,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
bmiModalButtonBackground: {
|
bmiModalButtonBackground: {
|
||||||
backgroundColor: '#192126',
|
backgroundColor: THEME_TEXT_PRIMARY,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
278
components/weight/WeightProgressBar.tsx
Normal file
278
components/weight/WeightProgressBar.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
ViewStyle
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
const THEME_PRIMARY = '#4F5BD5';
|
||||||
|
const THEME_SECONDARY = '#6B6CFF';
|
||||||
|
const THEME_SUCCESS = '#22C55E';
|
||||||
|
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||||
|
|
||||||
|
export interface WeightProgressBarProps {
|
||||||
|
/** 进度值 0-1 */
|
||||||
|
progress: number;
|
||||||
|
/** 当前体重 */
|
||||||
|
currentWeight: number;
|
||||||
|
/** 目标体重 */
|
||||||
|
targetWeight: number;
|
||||||
|
/** 初始体重 */
|
||||||
|
initialWeight: number;
|
||||||
|
/** 容器样式 */
|
||||||
|
style?: ViewStyle;
|
||||||
|
/** 是否显示顶部分隔线,默认 true */
|
||||||
|
showTopBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
|
||||||
|
progress,
|
||||||
|
currentWeight,
|
||||||
|
targetWeight,
|
||||||
|
initialWeight,
|
||||||
|
style,
|
||||||
|
showTopBorder = true,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||||
|
const [barWidth, setBarWidth] = useState(0);
|
||||||
|
|
||||||
|
const clampedProgress = Math.min(1, Math.max(0, progress));
|
||||||
|
const percent = Math.round(clampedProgress * 100);
|
||||||
|
|
||||||
|
// 判断是否有有效数据
|
||||||
|
const hasInitialWeight = initialWeight > 0;
|
||||||
|
const hasTargetWeight = targetWeight > 0;
|
||||||
|
const hasCurrentWeight = currentWeight > 0;
|
||||||
|
// 只要有初始体重和当前体重,就可以显示已减重量
|
||||||
|
const canShowLost = hasInitialWeight && hasCurrentWeight;
|
||||||
|
// 需要有目标体重才能显示距离目标和进度
|
||||||
|
const canShowTarget = hasTargetWeight && hasCurrentWeight;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Animated.timing(animatedProgress, {
|
||||||
|
toValue: clampedProgress,
|
||||||
|
duration: 800,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [clampedProgress]);
|
||||||
|
|
||||||
|
const fillWidth = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, barWidth],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderPosition = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-12, barWidth - 12],
|
||||||
|
});
|
||||||
|
|
||||||
|
const weightLost = initialWeight - currentWeight;
|
||||||
|
const weightToGo = currentWeight - targetWeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[
|
||||||
|
styles.container,
|
||||||
|
showTopBorder && styles.topBorder,
|
||||||
|
style
|
||||||
|
]}>
|
||||||
|
{/* 进度信息 */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
|
||||||
|
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.percentContainer}>
|
||||||
|
<Text style={styles.percentValue}>{percent}</Text>
|
||||||
|
<Text style={styles.percentSymbol}>%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
|
||||||
|
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
|
||||||
|
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<View
|
||||||
|
style={styles.trackContainer}
|
||||||
|
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
{/* 背景轨道 */}
|
||||||
|
<View style={styles.track} />
|
||||||
|
|
||||||
|
{/* 填充进度 */}
|
||||||
|
<Animated.View style={[styles.fill, { width: fillWidth }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[THEME_PRIMARY, THEME_SECONDARY]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 滑块 - 圆角矩形 */}
|
||||||
|
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#ffffff', '#f8f9fc']}
|
||||||
|
style={styles.sliderInner}
|
||||||
|
>
|
||||||
|
<View style={styles.sliderLine} />
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 起止标签 */}
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
|
||||||
|
<View style={styles.targetBadge}>
|
||||||
|
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
|
||||||
|
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
marginLeft:12,
|
||||||
|
marginRight: 12
|
||||||
|
},
|
||||||
|
topBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
percentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
percentValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
percentSymbol: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
trackContainer: {
|
||||||
|
height: 8,
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: '#E8EAF0',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
slider: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: THEME_PRIMARY,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
sliderInner: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: THEME_PRIMARY,
|
||||||
|
},
|
||||||
|
sliderLine: {
|
||||||
|
width: 8,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: THEME_PRIMARY,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
targetBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
targetText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WeightProgressBar;
|
||||||
@@ -49,6 +49,8 @@ export const ROUTES = {
|
|||||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||||
SLEEP_DETAIL: '/sleep-detail',
|
SLEEP_DETAIL: '/sleep-detail',
|
||||||
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
||||||
|
HEALTH_PROFILE: '/health/profile',
|
||||||
|
HEALTH_FAMILY_INVITE: '/health/family-invite',
|
||||||
|
|
||||||
// 饮水相关路由
|
// 饮水相关路由
|
||||||
WATER_DETAIL: '/water/detail',
|
WATER_DETAIL: '/water/detail',
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ export const statistics = {
|
|||||||
days: 'days',
|
days: 'days',
|
||||||
range: 'Range',
|
range: 'Range',
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
|
progress: {
|
||||||
|
lost: 'Lost',
|
||||||
|
toGo: 'To go',
|
||||||
|
},
|
||||||
|
demo: 'Demo',
|
||||||
bmiModal: {
|
bmiModal: {
|
||||||
title: 'BMI Index Explanation',
|
title: 'BMI Index Explanation',
|
||||||
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
|
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
|
||||||
@@ -205,13 +210,6 @@ export const statistics = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
|
||||||
health: 'Health',
|
|
||||||
medications: 'Meds',
|
|
||||||
fasting: 'Fasting',
|
|
||||||
challenges: 'Challenges',
|
|
||||||
personal: 'Me',
|
|
||||||
},
|
|
||||||
activityHeatMap: {
|
activityHeatMap: {
|
||||||
subtitle: 'Active {{days}} days in the last 6 months',
|
subtitle: 'Active {{days}} days in the last 6 months',
|
||||||
activeRate: '{{rate}}%',
|
activeRate: '{{rate}}%',
|
||||||
@@ -726,3 +724,92 @@ export const workoutHistory = {
|
|||||||
},
|
},
|
||||||
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const health = {
|
||||||
|
tabs: {
|
||||||
|
health: 'Health',
|
||||||
|
medications: 'Meds',
|
||||||
|
fasting: 'Fasting',
|
||||||
|
challenges: 'Challenges',
|
||||||
|
personal: 'Me',
|
||||||
|
healthProfile: {
|
||||||
|
title: 'Health Profile',
|
||||||
|
subtitle: 'Invite family to join health management for timely anomaly alerts',
|
||||||
|
privacyNotice: 'Profile content is visible only to you. We strictly protect your privacy.',
|
||||||
|
basicInfo: 'Basic Info',
|
||||||
|
healthHistory: 'History',
|
||||||
|
medicalRecords: 'Records',
|
||||||
|
checkupRecords: 'Checkups',
|
||||||
|
medicineBox: 'Medications',
|
||||||
|
basicInfoCard: {
|
||||||
|
title: 'Basic Information',
|
||||||
|
noData: 'No data',
|
||||||
|
bmi: 'BMI',
|
||||||
|
height: 'Height',
|
||||||
|
heightUnit: 'CM',
|
||||||
|
weight: 'Weight',
|
||||||
|
weightUnit: 'KG',
|
||||||
|
waist: 'Waist',
|
||||||
|
waistUnit: 'CM',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
allergy: 'Allergies',
|
||||||
|
disease: 'Conditions',
|
||||||
|
surgery: 'Surgeries',
|
||||||
|
familyDisease: 'Family History',
|
||||||
|
pending: 'To be added',
|
||||||
|
edit: 'Edit',
|
||||||
|
modal: {
|
||||||
|
question: 'Do you have {{type}}?',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
addDetails: 'Add Details',
|
||||||
|
enterSpecific: 'Enter specific condition...',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
save: 'Save',
|
||||||
|
none: 'None',
|
||||||
|
yesNoDetails: 'Yes (No details)',
|
||||||
|
diagnosisDate: 'Diagnosis Date',
|
||||||
|
namePlaceholder: 'Condition Name',
|
||||||
|
addItem: 'Add Record',
|
||||||
|
selectDate: 'Select Date'
|
||||||
|
},
|
||||||
|
recommendationItems: {
|
||||||
|
allergy: {
|
||||||
|
penicillin: 'Penicillin',
|
||||||
|
sulfonamides: 'Sulfonamides',
|
||||||
|
peanuts: 'Peanuts',
|
||||||
|
seafood: 'Seafood',
|
||||||
|
pollen: 'Pollen',
|
||||||
|
dustMites: 'Dust Mites',
|
||||||
|
alcohol: 'Alcohol',
|
||||||
|
mango: 'Mango'
|
||||||
|
},
|
||||||
|
disease: {
|
||||||
|
hypertension: 'Hypertension',
|
||||||
|
diabetes: 'Diabetes',
|
||||||
|
asthma: 'Asthma',
|
||||||
|
heartDisease: 'Heart Disease',
|
||||||
|
gastritis: 'Gastritis',
|
||||||
|
migraine: 'Migraine'
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
appendectomy: 'Appendectomy',
|
||||||
|
cesareanSection: 'Cesarean Section',
|
||||||
|
tonsillectomy: 'Tonsillectomy',
|
||||||
|
fractureRepair: 'Fracture Repair',
|
||||||
|
none: 'None'
|
||||||
|
},
|
||||||
|
familyDisease: {
|
||||||
|
hypertension: 'Hypertension',
|
||||||
|
diabetes: 'Diabetes',
|
||||||
|
cancer: 'Cancer',
|
||||||
|
heartDisease: 'Heart Disease',
|
||||||
|
stroke: 'Stroke',
|
||||||
|
alzheimers: 'Alzheimer\'s'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export const medications = {
|
|||||||
periodRange: 'From {{startDate}} to {{endDate}}',
|
periodRange: 'From {{startDate}} to {{endDate}}',
|
||||||
periodLongTerm: 'From {{startDate}} until indefinitely',
|
periodLongTerm: 'From {{startDate}} until indefinitely',
|
||||||
expiryStatus: {
|
expiryStatus: {
|
||||||
notSet: 'Not set',
|
notSet: 'Set Expiry',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
expiresToday: 'Expires today',
|
expiresToday: 'Expires today',
|
||||||
expiresInDays: 'Expires in {{days}} days',
|
expiresInDays: 'Expires in {{days}} days',
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const personal = {
|
|||||||
medicalSources: 'Medical Advice Sources',
|
medicalSources: 'Medical Advice Sources',
|
||||||
customization: 'Customization',
|
customization: 'Customization',
|
||||||
},
|
},
|
||||||
|
healthProfile: {
|
||||||
|
title: 'Health Profile',
|
||||||
|
subtitle: 'Manage your personal health data and family profile',
|
||||||
|
},
|
||||||
versionCheck: {
|
versionCheck: {
|
||||||
sectionTitle: 'Updates',
|
sectionTitle: 'Updates',
|
||||||
menuTitle: 'Check for updates',
|
menuTitle: 'Check for updates',
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export const statistics = {
|
|||||||
days: '天',
|
days: '天',
|
||||||
range: '范围',
|
range: '范围',
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
|
progress: {
|
||||||
|
lost: '已减',
|
||||||
|
toGo: '距目标',
|
||||||
|
},
|
||||||
|
demo: '示例数据',
|
||||||
bmiModal: {
|
bmiModal: {
|
||||||
title: 'BMI 指数说明',
|
title: 'BMI 指数说明',
|
||||||
description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标',
|
description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标',
|
||||||
@@ -206,13 +211,6 @@ export const statistics = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
|
||||||
health: '健康',
|
|
||||||
medications: '用药',
|
|
||||||
fasting: '断食',
|
|
||||||
challenges: '挑战',
|
|
||||||
personal: '个人',
|
|
||||||
},
|
|
||||||
activityHeatMap: {
|
activityHeatMap: {
|
||||||
subtitle: '最近6个月活跃 {{days}} 天',
|
subtitle: '最近6个月活跃 {{days}} 天',
|
||||||
activeRate: '{{rate}}%',
|
activeRate: '{{rate}}%',
|
||||||
@@ -727,3 +725,92 @@ export const workoutHistory = {
|
|||||||
},
|
},
|
||||||
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const health = {
|
||||||
|
tabs: {
|
||||||
|
health: '健康',
|
||||||
|
medications: '用药',
|
||||||
|
fasting: '断食',
|
||||||
|
challenges: '挑战',
|
||||||
|
personal: '个人',
|
||||||
|
healthProfile: {
|
||||||
|
title: '健康档案',
|
||||||
|
subtitle: '邀请家人加入家庭健康管理,异常及时提醒',
|
||||||
|
privacyNotice: '档案内容仅供本人查看,我们将严格保护您的隐私',
|
||||||
|
basicInfo: '基础信息',
|
||||||
|
healthHistory: '健康史',
|
||||||
|
medicalRecords: '就医资料',
|
||||||
|
checkupRecords: '体检记录',
|
||||||
|
medicineBox: '药品管理',
|
||||||
|
basicInfoCard: {
|
||||||
|
title: '基础信息',
|
||||||
|
noData: '暂无数据',
|
||||||
|
bmi: 'BMI',
|
||||||
|
height: '身高',
|
||||||
|
heightUnit: 'CM',
|
||||||
|
weight: '体重',
|
||||||
|
weightUnit: 'KG',
|
||||||
|
waist: '腰围',
|
||||||
|
waistUnit: 'CM',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
allergy: '过敏史',
|
||||||
|
disease: '疾病史',
|
||||||
|
surgery: '手术史',
|
||||||
|
familyDisease: '家族疾病史',
|
||||||
|
pending: '待补充',
|
||||||
|
edit: '编辑',
|
||||||
|
modal: {
|
||||||
|
question: '您是否有{{type}}?',
|
||||||
|
yes: '有',
|
||||||
|
no: '没有',
|
||||||
|
addDetails: '添加详情',
|
||||||
|
enterSpecific: '请输入具体情况...',
|
||||||
|
recommendations: '推荐选项',
|
||||||
|
save: '保存',
|
||||||
|
none: '无',
|
||||||
|
yesNoDetails: '有 (未填写详情)',
|
||||||
|
diagnosisDate: '确诊时间',
|
||||||
|
namePlaceholder: '疾病/手术名称',
|
||||||
|
addItem: '添加记录',
|
||||||
|
selectDate: '选择日期'
|
||||||
|
},
|
||||||
|
recommendationItems: {
|
||||||
|
allergy: {
|
||||||
|
penicillin: '青霉素',
|
||||||
|
sulfonamides: '磺胺类',
|
||||||
|
peanuts: '花生',
|
||||||
|
seafood: '海鲜',
|
||||||
|
pollen: '花粉',
|
||||||
|
dustMites: '尘螨',
|
||||||
|
alcohol: '酒精',
|
||||||
|
mango: '芒果'
|
||||||
|
},
|
||||||
|
disease: {
|
||||||
|
hypertension: '高血压',
|
||||||
|
diabetes: '糖尿病',
|
||||||
|
asthma: '哮喘',
|
||||||
|
heartDisease: '心脏病',
|
||||||
|
gastritis: '胃炎',
|
||||||
|
migraine: '偏头痛'
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
appendectomy: '阑尾切除术',
|
||||||
|
cesareanSection: '剖腹产',
|
||||||
|
tonsillectomy: '扁桃体切除术',
|
||||||
|
fractureRepair: '骨折复位术',
|
||||||
|
none: '无'
|
||||||
|
},
|
||||||
|
familyDisease: {
|
||||||
|
hypertension: '高血压',
|
||||||
|
diabetes: '糖尿病',
|
||||||
|
cancer: '癌症',
|
||||||
|
heartDisease: '心脏病',
|
||||||
|
stroke: '中风',
|
||||||
|
alzheimers: '阿尔茨海默病'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export const medications = {
|
|||||||
periodRange: '从 {{startDate}} 至 {{endDate}}',
|
periodRange: '从 {{startDate}} 至 {{endDate}}',
|
||||||
periodLongTerm: '从 {{startDate}} 至长期',
|
periodLongTerm: '从 {{startDate}} 至长期',
|
||||||
expiryStatus: {
|
expiryStatus: {
|
||||||
notSet: '未设置',
|
notSet: '未设置(过期预警)',
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
expiresToday: '今天到期',
|
expiresToday: '今天到期',
|
||||||
expiresInDays: '{{days}}天后到期',
|
expiresInDays: '{{days}}天后到期',
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const personal = {
|
|||||||
medicalSources: '医学建议来源',
|
medicalSources: '医学建议来源',
|
||||||
customization: '个性化',
|
customization: '个性化',
|
||||||
},
|
},
|
||||||
|
healthProfile: {
|
||||||
|
title: '健康档案',
|
||||||
|
subtitle: '管理您的个人健康数据与家庭档案',
|
||||||
|
},
|
||||||
versionCheck: {
|
versionCheck: {
|
||||||
sectionTitle: '版本与更新',
|
sectionTitle: '版本与更新',
|
||||||
menuTitle: '检查更新',
|
menuTitle: '检查更新',
|
||||||
|
|||||||
241
services/healthProfile.ts
Normal file
241
services/healthProfile.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* 健康档案 API 服务
|
||||||
|
* Base URL: /api/health-profiles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
// 健康史分类
|
||||||
|
export type HealthHistoryCategory = 'allergy' | 'disease' | 'surgery' | 'familyDisease';
|
||||||
|
|
||||||
|
// 健康史条目
|
||||||
|
export interface HealthHistoryItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
diagnosisDate?: string; // YYYY-MM-DD
|
||||||
|
isRecommendation?: boolean;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康史分类数据
|
||||||
|
export interface HealthHistoryCategoryData {
|
||||||
|
hasHistory: boolean | null;
|
||||||
|
items: HealthHistoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整健康史数据
|
||||||
|
export interface HealthHistoryData {
|
||||||
|
allergy: HealthHistoryCategoryData;
|
||||||
|
disease: HealthHistoryCategoryData;
|
||||||
|
surgery: HealthHistoryCategoryData;
|
||||||
|
familyDisease: HealthHistoryCategoryData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康档案概览
|
||||||
|
export interface HealthProfileOverview {
|
||||||
|
basicInfo: {
|
||||||
|
progress: number;
|
||||||
|
data: {
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
bmi: string;
|
||||||
|
waistCircumference: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
healthHistory: {
|
||||||
|
progress: number;
|
||||||
|
answeredCategories: HealthHistoryCategory[];
|
||||||
|
pendingCategories: HealthHistoryCategory[];
|
||||||
|
};
|
||||||
|
medications: {
|
||||||
|
activeCount: number;
|
||||||
|
todayCompletionRate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康史进度
|
||||||
|
export interface HealthHistoryProgress {
|
||||||
|
progress: number;
|
||||||
|
details: Record<HealthHistoryCategory, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新健康史请求
|
||||||
|
export interface UpdateHealthHistoryRequest {
|
||||||
|
hasHistory: boolean;
|
||||||
|
items?: Array<{
|
||||||
|
name: string;
|
||||||
|
date?: string;
|
||||||
|
isRecommendation?: boolean;
|
||||||
|
note?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 家庭健康管理类型 ====================
|
||||||
|
|
||||||
|
// 家庭成员角色
|
||||||
|
export type FamilyRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
|
// 家庭组
|
||||||
|
export interface FamilyGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: string;
|
||||||
|
memberCount: number;
|
||||||
|
maxMembers: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 家庭成员
|
||||||
|
export interface FamilyMember {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
role: FamilyRole;
|
||||||
|
relationship: string | null;
|
||||||
|
canViewHealthData: boolean;
|
||||||
|
canManageHealthData: boolean;
|
||||||
|
receiveAlerts: boolean;
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请码
|
||||||
|
export interface InviteCode {
|
||||||
|
inviteCode: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成员权限请求
|
||||||
|
export interface UpdateMemberPermissionsRequest {
|
||||||
|
role?: 'admin' | 'member';
|
||||||
|
canViewHealthData?: boolean;
|
||||||
|
canManageHealthData?: boolean;
|
||||||
|
receiveAlerts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 健康档案概览 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健康档案概览
|
||||||
|
*/
|
||||||
|
export async function getHealthProfileOverview(): Promise<HealthProfileOverview> {
|
||||||
|
return api.get<HealthProfileOverview>('/api/health-profiles/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 健康史 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整健康史
|
||||||
|
*/
|
||||||
|
export async function getHealthHistory(): Promise<HealthHistoryData> {
|
||||||
|
return api.get<HealthHistoryData>('/api/health-profiles/history');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新指定分类的健康史
|
||||||
|
* @param category 分类: allergy | disease | surgery | familyDisease
|
||||||
|
* @param data 更新数据
|
||||||
|
*/
|
||||||
|
export async function updateHealthHistory(
|
||||||
|
category: HealthHistoryCategory,
|
||||||
|
data: UpdateHealthHistoryRequest
|
||||||
|
): Promise<HealthHistoryCategoryData> {
|
||||||
|
return api.put<HealthHistoryCategoryData>(
|
||||||
|
`/api/health-profiles/history/${category}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健康史完成度
|
||||||
|
*/
|
||||||
|
export async function getHealthHistoryProgress(): Promise<HealthHistoryProgress> {
|
||||||
|
return api.get<HealthHistoryProgress>('/api/health-profiles/history/progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 家庭健康管理 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所属家庭组
|
||||||
|
*/
|
||||||
|
export async function getFamilyGroup(): Promise<FamilyGroup | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<FamilyGroup>('/api/health-profiles/family/group');
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果用户没有家庭组,返回 null
|
||||||
|
if (error?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建家庭组
|
||||||
|
* @param name 家庭组名称
|
||||||
|
*/
|
||||||
|
export async function createFamilyGroup(name: string): Promise<FamilyGroup> {
|
||||||
|
return api.post<FamilyGroup>('/api/health-profiles/family/group', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成家庭组邀请码
|
||||||
|
* @param expiresInHours 过期时间(小时),默认24小时
|
||||||
|
*/
|
||||||
|
export async function generateInviteCode(expiresInHours: number = 24): Promise<InviteCode> {
|
||||||
|
return api.post<InviteCode>('/api/health-profiles/family/group/invite', { expiresInHours });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过邀请码加入家庭组
|
||||||
|
* @param inviteCode 邀请码
|
||||||
|
* @param relationship 与创建者的关系(如:配偶、父母、子女等)
|
||||||
|
*/
|
||||||
|
export async function joinFamilyGroup(
|
||||||
|
inviteCode: string,
|
||||||
|
relationship: string
|
||||||
|
): Promise<FamilyGroup> {
|
||||||
|
return api.post<FamilyGroup>('/api/health-profiles/family/group/join', {
|
||||||
|
inviteCode,
|
||||||
|
relationship,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家庭成员列表
|
||||||
|
*/
|
||||||
|
export async function getFamilyMembers(): Promise<FamilyMember[]> {
|
||||||
|
return api.get<FamilyMember[]>('/api/health-profiles/family/members');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新家庭成员权限(仅 owner/admin 可操作)
|
||||||
|
* @param memberId 成员ID
|
||||||
|
* @param permissions 权限设置
|
||||||
|
*/
|
||||||
|
export async function updateFamilyMember(
|
||||||
|
memberId: string,
|
||||||
|
permissions: UpdateMemberPermissionsRequest
|
||||||
|
): Promise<FamilyMember> {
|
||||||
|
return api.put<FamilyMember>(
|
||||||
|
`/api/health-profiles/family/members/${memberId}`,
|
||||||
|
permissions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除家庭成员(仅 owner/admin 可操作)
|
||||||
|
* @param memberId 成员ID
|
||||||
|
*/
|
||||||
|
export async function removeFamilyMember(memberId: string): Promise<void> {
|
||||||
|
return api.delete(`/api/health-profiles/family/members/${memberId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出家庭组(非 owner 成员使用)
|
||||||
|
*/
|
||||||
|
export async function leaveFamilyGroup(): Promise<void> {
|
||||||
|
return api.post('/api/health-profiles/family/leave');
|
||||||
|
}
|
||||||
332
store/familyHealthSlice.ts
Normal file
332
store/familyHealthSlice.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* 家庭健康管理 Redux Slice
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import * as healthProfileApi from '@/services/healthProfile';
|
||||||
|
import { RootState } from './index';
|
||||||
|
|
||||||
|
// ==================== State 类型定义 ====================
|
||||||
|
|
||||||
|
export interface FamilyHealthState {
|
||||||
|
// 家庭组信息
|
||||||
|
familyGroup: healthProfileApi.FamilyGroup | null;
|
||||||
|
|
||||||
|
// 家庭成员列表
|
||||||
|
members: healthProfileApi.FamilyMember[];
|
||||||
|
|
||||||
|
// 邀请码信息
|
||||||
|
inviteCode: healthProfileApi.InviteCode | null;
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
loading: boolean;
|
||||||
|
membersLoading: boolean;
|
||||||
|
inviteLoading: boolean;
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始状态 ====================
|
||||||
|
|
||||||
|
const initialState: FamilyHealthState = {
|
||||||
|
familyGroup: null,
|
||||||
|
members: [],
|
||||||
|
inviteCode: null,
|
||||||
|
loading: false,
|
||||||
|
membersLoading: false,
|
||||||
|
inviteLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Async Thunks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所属家庭组
|
||||||
|
*/
|
||||||
|
export const fetchFamilyGroup = createAsyncThunk(
|
||||||
|
'familyHealth/fetchGroup',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.getFamilyGroup();
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '获取家庭组失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建家庭组
|
||||||
|
*/
|
||||||
|
export const createFamilyGroup = createAsyncThunk(
|
||||||
|
'familyHealth/createGroup',
|
||||||
|
async (name: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.createFamilyGroup(name);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '创建家庭组失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成邀请码
|
||||||
|
*/
|
||||||
|
export const generateInviteCode = createAsyncThunk(
|
||||||
|
'familyHealth/generateInvite',
|
||||||
|
async (expiresInHours: number = 24, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.generateInviteCode(expiresInHours);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '生成邀请码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入家庭组
|
||||||
|
*/
|
||||||
|
export const joinFamilyGroup = createAsyncThunk(
|
||||||
|
'familyHealth/joinGroup',
|
||||||
|
async (
|
||||||
|
{ inviteCode, relationship }: { inviteCode: string; relationship: string },
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.joinFamilyGroup(inviteCode, relationship);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '加入家庭组失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家庭成员列表
|
||||||
|
*/
|
||||||
|
export const fetchFamilyMembers = createAsyncThunk(
|
||||||
|
'familyHealth/fetchMembers',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.getFamilyMembers();
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '获取家庭成员失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新家庭成员权限
|
||||||
|
*/
|
||||||
|
export const updateFamilyMember = createAsyncThunk(
|
||||||
|
'familyHealth/updateMember',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
memberId,
|
||||||
|
permissions,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
permissions: healthProfileApi.UpdateMemberPermissionsRequest;
|
||||||
|
},
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.updateFamilyMember(memberId, permissions);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '更新成员权限失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除家庭成员
|
||||||
|
*/
|
||||||
|
export const removeFamilyMember = createAsyncThunk(
|
||||||
|
'familyHealth/removeMember',
|
||||||
|
async (memberId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await healthProfileApi.removeFamilyMember(memberId);
|
||||||
|
return memberId;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '移除成员失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出家庭组
|
||||||
|
*/
|
||||||
|
export const leaveFamilyGroup = createAsyncThunk(
|
||||||
|
'familyHealth/leaveGroup',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await healthProfileApi.leaveFamilyGroup();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '退出家庭组失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Slice ====================
|
||||||
|
|
||||||
|
const familyHealthSlice = createSlice({
|
||||||
|
name: 'familyHealth',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 清除错误
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除邀请码
|
||||||
|
clearInviteCode: (state) => {
|
||||||
|
state.inviteCode = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置状态(用于登出时)
|
||||||
|
resetFamilyHealth: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// 获取家庭组
|
||||||
|
.addCase(fetchFamilyGroup.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchFamilyGroup.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.familyGroup = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchFamilyGroup.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建家庭组
|
||||||
|
.addCase(createFamilyGroup.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(createFamilyGroup.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.familyGroup = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(createFamilyGroup.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成邀请码
|
||||||
|
.addCase(generateInviteCode.pending, (state) => {
|
||||||
|
state.inviteLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(generateInviteCode.fulfilled, (state, action) => {
|
||||||
|
state.inviteLoading = false;
|
||||||
|
state.inviteCode = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(generateInviteCode.rejected, (state, action) => {
|
||||||
|
state.inviteLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加入家庭组
|
||||||
|
.addCase(joinFamilyGroup.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(joinFamilyGroup.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.familyGroup = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(joinFamilyGroup.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取家庭成员
|
||||||
|
.addCase(fetchFamilyMembers.pending, (state) => {
|
||||||
|
state.membersLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchFamilyMembers.fulfilled, (state, action) => {
|
||||||
|
state.membersLoading = false;
|
||||||
|
state.members = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchFamilyMembers.rejected, (state, action) => {
|
||||||
|
state.membersLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新成员权限
|
||||||
|
.addCase(updateFamilyMember.fulfilled, (state, action) => {
|
||||||
|
const updatedMember = action.payload;
|
||||||
|
const index = state.members.findIndex((m) => m.id === updatedMember.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.members[index] = updatedMember;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateFamilyMember.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移除成员
|
||||||
|
.addCase(removeFamilyMember.fulfilled, (state, action) => {
|
||||||
|
const memberId = action.payload;
|
||||||
|
state.members = state.members.filter((m) => m.id !== memberId);
|
||||||
|
if (state.familyGroup) {
|
||||||
|
state.familyGroup.memberCount = Math.max(0, state.familyGroup.memberCount - 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(removeFamilyMember.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 退出家庭组
|
||||||
|
.addCase(leaveFamilyGroup.fulfilled, (state) => {
|
||||||
|
state.familyGroup = null;
|
||||||
|
state.members = [];
|
||||||
|
state.inviteCode = null;
|
||||||
|
})
|
||||||
|
.addCase(leaveFamilyGroup.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
export const { clearError, clearInviteCode, resetFamilyHealth } = familyHealthSlice.actions;
|
||||||
|
|
||||||
|
// ==================== Selectors ====================
|
||||||
|
|
||||||
|
export const selectFamilyGroup = (state: RootState) => state.familyHealth.familyGroup;
|
||||||
|
export const selectFamilyMembers = (state: RootState) => state.familyHealth.members;
|
||||||
|
export const selectInviteCode = (state: RootState) => state.familyHealth.inviteCode;
|
||||||
|
export const selectFamilyHealthLoading = (state: RootState) => state.familyHealth.loading;
|
||||||
|
export const selectFamilyMembersLoading = (state: RootState) => state.familyHealth.membersLoading;
|
||||||
|
export const selectInviteLoading = (state: RootState) => state.familyHealth.inviteLoading;
|
||||||
|
export const selectFamilyHealthError = (state: RootState) => state.familyHealth.error;
|
||||||
|
|
||||||
|
// 判断当前用户是否是家庭组 owner
|
||||||
|
export const selectIsOwner = (state: RootState) => {
|
||||||
|
const currentUserId = state.user.profile?.id;
|
||||||
|
const familyGroup = state.familyHealth.familyGroup;
|
||||||
|
return currentUserId && familyGroup && familyGroup.ownerId === currentUserId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 判断当前用户是否是管理员(owner 或 admin)
|
||||||
|
export const selectIsAdmin = (state: RootState) => {
|
||||||
|
const currentUserId = state.user.profile?.id;
|
||||||
|
const members = state.familyHealth.members;
|
||||||
|
const currentMember = members.find((m) => m.userId === currentUserId);
|
||||||
|
return currentMember && (currentMember.role === 'owner' || currentMember.role === 'admin');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default familyHealthSlice.reducer;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import * as healthProfileApi from '@/services/healthProfile';
|
||||||
import { AppDispatch, RootState } from './index';
|
import { AppDispatch, RootState } from './index';
|
||||||
|
|
||||||
// 健康数据类型定义
|
// 健康数据类型定义
|
||||||
@@ -22,10 +23,28 @@ export interface HealthData {
|
|||||||
standHoursGoal: number;
|
standHoursGoal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 健康史数据类型定义
|
||||||
|
export interface HistoryItemDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
date?: string; // ISO Date string
|
||||||
|
isRecommendation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryData {
|
||||||
|
[key: string]: {
|
||||||
|
hasHistory: boolean | null;
|
||||||
|
items: HistoryItemDetail[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface HealthState {
|
export interface HealthState {
|
||||||
// 按日期存储的历史数据
|
// 按日期存储的历史数据
|
||||||
dataByDate: Record<string, HealthData>;
|
dataByDate: Record<string, HealthData>;
|
||||||
|
|
||||||
|
// 健康史数据
|
||||||
|
historyData: HistoryData;
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -37,6 +56,12 @@ export interface HealthState {
|
|||||||
// 初始状态
|
// 初始状态
|
||||||
const initialState: HealthState = {
|
const initialState: HealthState = {
|
||||||
dataByDate: {},
|
dataByDate: {},
|
||||||
|
historyData: {
|
||||||
|
allergy: { hasHistory: null, items: [] },
|
||||||
|
disease: { hasHistory: null, items: [] },
|
||||||
|
surgery: { hasHistory: null, items: [] },
|
||||||
|
familyDisease: { hasHistory: null, items: [] },
|
||||||
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdateTime: null,
|
lastUpdateTime: null,
|
||||||
@@ -82,6 +107,96 @@ const healthSlice = createSlice({
|
|||||||
state.error = null;
|
state.error = null;
|
||||||
state.lastUpdateTime = null;
|
state.lastUpdateTime = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 更新健康史数据(本地更新,用于乐观更新或离线模式)
|
||||||
|
updateHistoryData: (state, action: PayloadAction<{
|
||||||
|
type: string;
|
||||||
|
data: {
|
||||||
|
hasHistory: boolean | null;
|
||||||
|
items: HistoryItemDetail[];
|
||||||
|
};
|
||||||
|
}>) => {
|
||||||
|
const { type, data } = action.payload;
|
||||||
|
state.historyData[type] = data;
|
||||||
|
state.lastUpdateTime = new Date().toISOString();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置完整的健康史数据(从服务端同步)
|
||||||
|
setHistoryData: (state, action: PayloadAction<HistoryData>) => {
|
||||||
|
state.historyData = action.payload;
|
||||||
|
state.lastUpdateTime = new Date().toISOString();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除健康史数据
|
||||||
|
clearHistoryData: (state) => {
|
||||||
|
state.historyData = {
|
||||||
|
allergy: { hasHistory: null, items: [] },
|
||||||
|
disease: { hasHistory: null, items: [] },
|
||||||
|
surgery: { hasHistory: null, items: [] },
|
||||||
|
familyDisease: { hasHistory: null, items: [] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// 获取健康史
|
||||||
|
.addCase(fetchHealthHistory.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchHealthHistory.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
// 转换服务端数据格式到本地格式
|
||||||
|
const serverData = action.payload;
|
||||||
|
const categories = ['allergy', 'disease', 'surgery', 'familyDisease'] as const;
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
if (serverData[category]) {
|
||||||
|
state.historyData[category] = {
|
||||||
|
hasHistory: serverData[category].hasHistory,
|
||||||
|
items: serverData[category].items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
date: item.diagnosisDate,
|
||||||
|
isRecommendation: item.isRecommendation,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.lastUpdateTime = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchHealthHistory.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// 保存健康史分类
|
||||||
|
.addCase(saveHealthHistoryCategory.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(saveHealthHistoryCategory.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
const { category, data } = action.payload;
|
||||||
|
// 更新对应分类的数据
|
||||||
|
state.historyData[category] = {
|
||||||
|
hasHistory: data.hasHistory,
|
||||||
|
items: data.items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
date: item.diagnosisDate,
|
||||||
|
isRecommendation: item.isRecommendation,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
state.lastUpdateTime = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(saveHealthHistoryCategory.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// 获取健康史进度
|
||||||
|
.addCase(fetchHealthHistoryProgress.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +207,9 @@ export const {
|
|||||||
setHealthData,
|
setHealthData,
|
||||||
clearHealthDataForDate,
|
clearHealthDataForDate,
|
||||||
clearAllHealthData,
|
clearAllHealthData,
|
||||||
|
updateHistoryData,
|
||||||
|
setHistoryData,
|
||||||
|
clearHistoryData,
|
||||||
} = 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
|
||||||
@@ -112,10 +230,84 @@ export const fetchHealthDataForDate = (date: Date) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 健康史 API Thunks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从服务端获取完整健康史数据
|
||||||
|
*/
|
||||||
|
export const fetchHealthHistory = createAsyncThunk(
|
||||||
|
'health/fetchHistory',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.getHealthHistory();
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '获取健康史失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存健康史分类到服务端
|
||||||
|
*/
|
||||||
|
export const saveHealthHistoryCategory = createAsyncThunk(
|
||||||
|
'health/saveHistoryCategory',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
category,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
category: healthProfileApi.HealthHistoryCategory;
|
||||||
|
data: healthProfileApi.UpdateHealthHistoryRequest;
|
||||||
|
},
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const result = await healthProfileApi.updateHealthHistory(category, data);
|
||||||
|
return { category, data: result };
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '保存健康史失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健康史完成进度
|
||||||
|
*/
|
||||||
|
export const fetchHealthHistoryProgress = createAsyncThunk(
|
||||||
|
'health/fetchHistoryProgress',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await healthProfileApi.getHealthHistoryProgress();
|
||||||
|
return data;
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
// 计算健康史完成度的 selector
|
||||||
|
export const selectHealthHistoryProgress = (state: RootState) => {
|
||||||
|
const historyData = state.health.historyData;
|
||||||
|
const categories = ['allergy', 'disease', 'surgery', 'familyDisease'];
|
||||||
|
|
||||||
|
let answeredCount = 0;
|
||||||
|
categories.forEach(category => {
|
||||||
|
const data = historyData[category];
|
||||||
|
// 只要回答了是否有历史(hasHistory !== null),就算已完成
|
||||||
|
if (data && data.hasHistory !== null) {
|
||||||
|
answeredCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.round((answeredCount / categories.length) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
export default healthSlice.reducer;
|
export default healthSlice.reducer;
|
||||||
@@ -5,6 +5,7 @@ import challengesReducer from './challengesSlice';
|
|||||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
import circumferenceReducer from './circumferenceSlice';
|
import circumferenceReducer from './circumferenceSlice';
|
||||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||||
|
import familyHealthReducer from './familyHealthSlice';
|
||||||
import fastingReducer, {
|
import fastingReducer, {
|
||||||
clearActiveSchedule,
|
clearActiveSchedule,
|
||||||
completeActiveSchedule,
|
completeActiveSchedule,
|
||||||
@@ -101,6 +102,7 @@ export const store = configureStore({
|
|||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
circumference: circumferenceReducer,
|
circumference: circumferenceReducer,
|
||||||
health: healthReducer,
|
health: healthReducer,
|
||||||
|
familyHealth: familyHealthReducer,
|
||||||
mood: moodReducer,
|
mood: moodReducer,
|
||||||
nutrition: nutritionReducer,
|
nutrition: nutritionReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
|
|||||||
@@ -1745,6 +1745,48 @@ export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取指定时间范围内的所有HRV样本
|
||||||
|
export async function fetchHRVSamples(startDate: Date, endDate: Date): Promise<HRVData[]> {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
limit: 1000 // 获取足够多的样本
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await HealthKitManager.getHeartRateVariabilitySamples(options);
|
||||||
|
|
||||||
|
if (result && result.data && Array.isArray(result.data)) {
|
||||||
|
const samples: HRVData[] = [];
|
||||||
|
|
||||||
|
for (const sample of result.data) {
|
||||||
|
const validatedValue = validateHRVValue(sample.value);
|
||||||
|
if (validatedValue !== null) {
|
||||||
|
samples.push({
|
||||||
|
value: validatedValue,
|
||||||
|
recordedAt: sample.startDate,
|
||||||
|
endDate: sample.endDate,
|
||||||
|
source: {
|
||||||
|
name: sample.source?.name || 'Unknown',
|
||||||
|
bundleIdentifier: sample.source?.bundleIdentifier || ''
|
||||||
|
},
|
||||||
|
isManualMeasurement: sample.isManualMeasurement || false,
|
||||||
|
qualityScore: sample.qualityScore,
|
||||||
|
sampleId: sample.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取HRV样本列表失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === 锻炼记录相关方法 ===
|
// === 锻炼记录相关方法 ===
|
||||||
|
|
||||||
// 获取最近锻炼记录
|
// 获取最近锻炼记录
|
||||||
|
|||||||
Reference in New Issue
Block a user