feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
},
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
"kiroAgent.configureMCP": "Enabled",
|
||||
"codingcopilot.enableCompletionLanguage": {}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ type TabConfig = {
|
||||
};
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
|
||||
};
|
||||
|
||||
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 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.cardContainer, {
|
||||
backgroundColor: 'transparent'
|
||||
}]}>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.healthProfileCard}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
|
||||
>
|
||||
<LinearGradient
|
||||
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>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
|
||||
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
|
||||
</View>
|
||||
<View style={styles.healthProfileRight}>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -824,7 +811,7 @@ export default function PersonalScreen() {
|
||||
>
|
||||
<UserHeader />
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||
<StatsSection />
|
||||
<HealthProfileEntry />
|
||||
<BadgesPreviewSection />
|
||||
<View style={styles.fishRecordContainer}>
|
||||
{/* <Image
|
||||
@@ -1315,4 +1302,60 @@ const styles = StyleSheet.create({
|
||||
color: '#9370DB',
|
||||
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,
|
||||
elevation: 3,
|
||||
},
|
||||
hrvTestButton: {
|
||||
backgroundColor: '#8B5CF6',
|
||||
},
|
||||
debugButtonText: {
|
||||
fontSize: 12,
|
||||
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 { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { WeightProgressBar } from '@/components/weight/WeightProgressBar';
|
||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
@@ -39,14 +41,16 @@ export default function WeightRecordsPage() {
|
||||
|
||||
const colorScheme = useColorScheme();
|
||||
const themeColors = Colors[colorScheme ?? 'light'];
|
||||
const { isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const loadWeightHistory = useCallback(async () => {
|
||||
if (!isLoggedIn) return;
|
||||
try {
|
||||
await dispatch(fetchWeightHistory() as any);
|
||||
} catch (error) {
|
||||
console.error(t('weightRecords.loadingHistory'), error);
|
||||
}
|
||||
}, [dispatch]);
|
||||
}, [dispatch, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWeightHistory();
|
||||
@@ -56,28 +60,36 @@ export default function WeightRecordsPage() {
|
||||
setInputWeight(weight.toString());
|
||||
};
|
||||
|
||||
const handleAddWeight = () => {
|
||||
const handleAddWeight = async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setPickerType('current');
|
||||
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
|
||||
initializeInput(weight);
|
||||
setShowWeightPicker(true);
|
||||
};
|
||||
|
||||
const handleEditInitialWeight = () => {
|
||||
const handleEditInitialWeight = async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setPickerType('initial');
|
||||
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
|
||||
initializeInput(parseFloat(initialWeight));
|
||||
setShowWeightPicker(true);
|
||||
};
|
||||
|
||||
const handleEditTargetWeight = () => {
|
||||
const handleEditTargetWeight = async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setPickerType('target');
|
||||
const targetWeight = userProfile?.targetWeight || '60.0';
|
||||
initializeInput(parseFloat(targetWeight));
|
||||
setShowWeightPicker(true);
|
||||
};
|
||||
|
||||
const handleEditWeightRecord = (record: WeightHistoryItem) => {
|
||||
const handleEditWeightRecord = async (record: WeightHistoryItem) => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setPickerType('edit');
|
||||
setEditingRecord(record);
|
||||
initializeInput(parseFloat(record.weight));
|
||||
@@ -85,6 +97,8 @@ export default function WeightRecordsPage() {
|
||||
};
|
||||
|
||||
const handleDeleteWeightRecord = async (id: string) => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
try {
|
||||
await dispatch(deleteWeightRecord(id) as any);
|
||||
await loadWeightHistory();
|
||||
@@ -180,6 +194,12 @@ export default function WeightRecordsPage() {
|
||||
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
{/* 背景 */}
|
||||
@@ -295,6 +315,19 @@ export default function WeightRecordsPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 减重进度条 - 仅在设置了目标体重时显示 */}
|
||||
{hasTargetWeight && (
|
||||
<View style={styles.progressContainer}>
|
||||
<WeightProgressBar
|
||||
progress={weightProgress}
|
||||
currentWeight={currentWeight}
|
||||
targetWeight={targetWeight}
|
||||
initialWeight={initialWeight}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Monthly Records */}
|
||||
{Object.keys(groupedHistory).length > 0 ? (
|
||||
<View style={styles.historySection}>
|
||||
@@ -628,6 +661,20 @@ const styles = StyleSheet.create({
|
||||
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
|
||||
historySection: {
|
||||
paddingHorizontal: 24,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
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 React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
|
||||
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) {
|
||||
const colorScheme = useColorScheme();
|
||||
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 = {
|
||||
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
|
||||
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
|
||||
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
|
||||
// 当前压力状态
|
||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||
const stressInfo = getStressLevelInfo(stressIndex);
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
@@ -45,23 +136,58 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
<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情况 */}
|
||||
<Text style={styles.sectionTitle}>最近30天HRV情况</Text>
|
||||
<Text style={styles.sectionTitle}>最近30天压力分布</Text>
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* 彩色横条图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.colorBar}>
|
||||
<LinearGradient
|
||||
colors={['#F59E0B', '#3B82F6', '#10B981']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
/>
|
||||
{historyData.totalSamples > 0 ? (
|
||||
<View style={styles.progressBarContainer}>
|
||||
{historyData.stressed.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.energetic.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.goodEvents.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||
@@ -84,41 +210,65 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.goodEvents.range}</Text>
|
||||
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
|
||||
HRV {historyData.goodEvents.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.goodEvents.count}次</Text>
|
||||
<Text style={styles.statCount}>{historyData.goodEvents.count}次</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.energetic.range}</Text>
|
||||
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
|
||||
HRV {historyData.energetic.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.energetic.count}次</Text>
|
||||
<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}>{hrvData.stressed.percentage}%</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.stressed.range}</Text>
|
||||
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
|
||||
HRV {historyData.stressed.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.stressed.count}次</Text>
|
||||
<Text style={styles.statCount}>{historyData.stressed.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部继续按钮 */}
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
|
||||
<View style={styles.buttonBackground}>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(139, 92, 246, 0.85)"
|
||||
isInteractive={true}
|
||||
style={styles.glassButton}
|
||||
>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</View>
|
||||
</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>
|
||||
<View style={styles.homeIndicator} />
|
||||
</View>
|
||||
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
marginTop: 24,
|
||||
marginBottom: 32,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
currentStatusCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
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',
|
||||
color: '#111827',
|
||||
lineHeight: 36,
|
||||
},
|
||||
hrvUnit: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginLeft: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 20,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
chartContainer: {
|
||||
marginBottom: 32,
|
||||
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
progressBarContainer: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
progressSegment: {
|
||||
height: '100%',
|
||||
},
|
||||
gradientBar: {
|
||||
flex: 1,
|
||||
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
legendDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 6,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginRight: 8,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#4B5563',
|
||||
},
|
||||
statsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
marginBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
marginBottom: 24,
|
||||
gap: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
},
|
||||
statTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statPercentage: {
|
||||
fontSize: 36,
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
statDetails: {
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statRange: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#DC2626',
|
||||
backgroundColor: '#FEE2E2',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statCount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
},
|
||||
bottomContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 34,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
continueButton: {
|
||||
borderRadius: 25,
|
||||
borderRadius: 28,
|
||||
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: {
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonBackground: {
|
||||
backgroundColor: Colors.light.accentGreen, // 应用主色调
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126', // 主色调上的文字颜色
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
homeIndicator: {
|
||||
width: 134,
|
||||
height: 5,
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
|
||||
borderRadius: 3,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hrvValue, setHrvValue] = useState(0)
|
||||
const [updateTime, setUpdateTime] = useState<Date>(new Date())
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
if (result.hrvData) {
|
||||
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`);
|
||||
} else {
|
||||
console.log('StressMeter: No HRV data obtained');
|
||||
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 渐变背景进度条 */}
|
||||
<View style={[styles.progressBar, { width: '100%' }]}>
|
||||
<LinearGradient
|
||||
colors={['#EF4444', '#FCD34D', '#10B981']}
|
||||
colors={['#10B981', '#FCD34D', '#EF4444']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
visible={showStressModal}
|
||||
onClose={() => setShowStressModal(false)}
|
||||
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 { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -20,18 +21,26 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} 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 CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
|
||||
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
|
||||
const CHART_HEIGHT = 60;
|
||||
const CARD_WIDTH = screenWidth - 40;
|
||||
const CHART_WIDTH = CARD_WIDTH - 36;
|
||||
const CHART_HEIGHT = 70;
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
|
||||
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
|
||||
|
||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
loadWeightHistory();
|
||||
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCoach = () => {
|
||||
// 点击添加按钮 - 需要登录
|
||||
const handleAddWeight = () => {
|
||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||
};
|
||||
|
||||
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
|
||||
setShowBMIModal(false);
|
||||
};
|
||||
|
||||
// 点击卡片 - 直接跳转,不需要登录
|
||||
const navigateToWeightRecords = () => {
|
||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||
router.push(ROUTES.WEIGHT_RECORDS);
|
||||
};
|
||||
|
||||
|
||||
// Process weight history data
|
||||
const sortedHistory = [...weightHistory]
|
||||
.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}>
|
||||
// <View style={styles.cardHeader}>
|
||||
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
// </View>
|
||||
// 是否有数据
|
||||
const hasData = sortedHistory.length > 0;
|
||||
|
||||
// <View style={styles.emptyContent}>
|
||||
// <Text style={styles.emptyDescription}>
|
||||
// No weight records yet, click the button below to start recording
|
||||
// </Text>
|
||||
// <TouchableOpacity
|
||||
// style={styles.recordButton}
|
||||
// onPress={(e) => {
|
||||
// e.stopPropagation();
|
||||
// navigateToCoach();
|
||||
// }}
|
||||
// activeOpacity={0.8}
|
||||
// >
|
||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
|
||||
// </TouchableOpacity>
|
||||
// </View>
|
||||
// </TouchableOpacity>
|
||||
// );
|
||||
// }
|
||||
// 计算减重进度
|
||||
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
|
||||
const initialWeight = userProfile?.initialWeight
|
||||
? parseFloat(userProfile.initialWeight)
|
||||
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
|
||||
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
|
||||
|
||||
// 计算进度百分比
|
||||
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||
const totalToLose = initialWeight - targetWeight;
|
||||
const actualLost = initialWeight - currentWeight;
|
||||
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||
|
||||
// Generate chart data
|
||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||
const minWeight = Math.min(...weights);
|
||||
const maxWeight = Math.max(...weights);
|
||||
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
|
||||
const minWeight = hasData ? Math.min(...weights) : 0;
|
||||
const maxWeight = hasData ? Math.max(...weights) : 0;
|
||||
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 normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||||
// Reduce top margin, compress whitespace
|
||||
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
|
||||
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
|
||||
return { x, y, weight: item.weight, date: item.createdAt };
|
||||
});
|
||||
}) : [];
|
||||
|
||||
// Generate path
|
||||
const pathData = points.map((point, index) => {
|
||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
||||
return `L ${point.x} ${point.y}`;
|
||||
}).join(' ');
|
||||
// 生成平滑曲线路径(使用贝塞尔曲线)
|
||||
const generateSmoothPath = (pts: typeof points) => {
|
||||
if (pts.length === 0) return '';
|
||||
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||
|
||||
// If there's only one data point, display as a horizontal line
|
||||
const singlePointPath = points.length === 1 ?
|
||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||
pathData;
|
||||
let path = `M ${pts[0].x} ${pts[0].y}`;
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
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 (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
handleAddWeight();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<GlassView style={styles.addButtonGlass}>
|
||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
|
||||
style={styles.addButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
handleAddWeight();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Default chart display */}
|
||||
{sortedHistory.length > 0 && (
|
||||
{/* 当前体重显示 */}
|
||||
<View style={styles.currentWeightSection}>
|
||||
<View style={styles.weightValueContainer}>
|
||||
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
|
||||
<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>
|
||||
|
||||
{/* 图表显示 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
{/* Background grid lines */}
|
||||
<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>
|
||||
|
||||
{/* More abstract line - reduce line width and display details */}
|
||||
{hasData ? (
|
||||
<>
|
||||
{/* 平滑曲线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke={Colors.light.accentGreen}
|
||||
strokeWidth={2}
|
||||
stroke="url(#lineGradient)"
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* Simplified data points - smaller and more refined */}
|
||||
{/* 数据点 */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{/* 外圈光晕 */}
|
||||
{isLastPoint && (
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 3 : 2}
|
||||
fill={Colors.light.accentGreen}
|
||||
opacity={0.9}
|
||||
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
|
||||
d={emptyLinePath}
|
||||
stroke="#E8EAF0"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="8,6"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>
|
||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||||
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 减重进度条 - 始终显示 */}
|
||||
<WeightProgressBar
|
||||
progress={weightProgress}
|
||||
currentWeight={currentWeight}
|
||||
targetWeight={targetWeight}
|
||||
initialWeight={initialWeight}
|
||||
/>
|
||||
|
||||
{/* BMI information modal */}
|
||||
<Modal
|
||||
@@ -323,32 +401,38 @@ export function WeightHistoryCard() {
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 22,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginTop: 16
|
||||
borderRadius: 24,
|
||||
padding: 18,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
marginTop: 16,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconSquare: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 4,
|
||||
marginRight: 10,
|
||||
},
|
||||
iconSquare: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
tintColor: THEME_PRIMARY,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontSize: 15,
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
flex: 1,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
headerButtons: {
|
||||
@@ -364,19 +448,56 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||
},
|
||||
addButtonGlass: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: '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: {
|
||||
alignItems: 'center',
|
||||
@@ -384,12 +505,12 @@ const styles = StyleSheet.create({
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
color: '#687076',
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
@@ -397,14 +518,14 @@ const styles = StyleSheet.create({
|
||||
recordButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.light.accentGreen,
|
||||
backgroundColor: THEME_PRIMARY,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
recordButtonText: {
|
||||
color: '#192126',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
@@ -418,20 +539,25 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
width: '100%',
|
||||
marginTop: -14,
|
||||
},
|
||||
infoItem: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.06)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 10,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
color: '#687076',
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
},
|
||||
|
||||
// BMI modal styles
|
||||
@@ -556,7 +682,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 8,
|
||||
},
|
||||
bmiModalButtonBackground: {
|
||||
backgroundColor: '#192126',
|
||||
backgroundColor: THEME_TEXT_PRIMARY,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 16,
|
||||
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',
|
||||
SLEEP_DETAIL: '/sleep-detail',
|
||||
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
||||
HEALTH_PROFILE: '/health/profile',
|
||||
HEALTH_FAMILY_INVITE: '/health/family-invite',
|
||||
|
||||
// 饮水相关路由
|
||||
WATER_DETAIL: '/water/detail',
|
||||
|
||||
@@ -176,6 +176,11 @@ export const statistics = {
|
||||
days: 'days',
|
||||
range: 'Range',
|
||||
unit: 'kg',
|
||||
progress: {
|
||||
lost: 'Lost',
|
||||
toGo: 'To go',
|
||||
},
|
||||
demo: 'Demo',
|
||||
bmiModal: {
|
||||
title: 'BMI Index Explanation',
|
||||
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: {
|
||||
subtitle: 'Active {{days}} days in the last 6 months',
|
||||
activeRate: '{{rate}}%',
|
||||
@@ -726,3 +724,92 @@ export const workoutHistory = {
|
||||
},
|
||||
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}}',
|
||||
periodLongTerm: 'From {{startDate}} until indefinitely',
|
||||
expiryStatus: {
|
||||
notSet: 'Not set',
|
||||
notSet: 'Set Expiry',
|
||||
expired: 'Expired',
|
||||
expiresToday: 'Expires today',
|
||||
expiresInDays: 'Expires in {{days}} days',
|
||||
|
||||
@@ -37,6 +37,10 @@ export const personal = {
|
||||
medicalSources: 'Medical Advice Sources',
|
||||
customization: 'Customization',
|
||||
},
|
||||
healthProfile: {
|
||||
title: 'Health Profile',
|
||||
subtitle: 'Manage your personal health data and family profile',
|
||||
},
|
||||
versionCheck: {
|
||||
sectionTitle: 'Updates',
|
||||
menuTitle: 'Check for updates',
|
||||
|
||||
@@ -177,6 +177,11 @@ export const statistics = {
|
||||
days: '天',
|
||||
range: '范围',
|
||||
unit: 'kg',
|
||||
progress: {
|
||||
lost: '已减',
|
||||
toGo: '距目标',
|
||||
},
|
||||
demo: '示例数据',
|
||||
bmiModal: {
|
||||
title: 'BMI 指数说明',
|
||||
description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标',
|
||||
@@ -206,13 +211,6 @@ export const statistics = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
health: '健康',
|
||||
medications: '用药',
|
||||
fasting: '断食',
|
||||
challenges: '挑战',
|
||||
personal: '个人',
|
||||
},
|
||||
activityHeatMap: {
|
||||
subtitle: '最近6个月活跃 {{days}} 天',
|
||||
activeRate: '{{rate}}%',
|
||||
@@ -727,3 +725,92 @@ export const workoutHistory = {
|
||||
},
|
||||
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}}',
|
||||
periodLongTerm: '从 {{startDate}} 至长期',
|
||||
expiryStatus: {
|
||||
notSet: '未设置',
|
||||
notSet: '未设置(过期预警)',
|
||||
expired: '已过期',
|
||||
expiresToday: '今天到期',
|
||||
expiresInDays: '{{days}}天后到期',
|
||||
|
||||
@@ -37,6 +37,10 @@ export const personal = {
|
||||
medicalSources: '医学建议来源',
|
||||
customization: '个性化',
|
||||
},
|
||||
healthProfile: {
|
||||
title: '健康档案',
|
||||
subtitle: '管理您的个人健康数据与家庭档案',
|
||||
},
|
||||
versionCheck: {
|
||||
sectionTitle: '版本与更新',
|
||||
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';
|
||||
|
||||
// 健康数据类型定义
|
||||
@@ -22,10 +23,28 @@ export interface HealthData {
|
||||
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 {
|
||||
// 按日期存储的历史数据
|
||||
dataByDate: Record<string, HealthData>;
|
||||
|
||||
// 健康史数据
|
||||
historyData: HistoryData;
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -37,6 +56,12 @@ export interface HealthState {
|
||||
// 初始状态
|
||||
const initialState: HealthState = {
|
||||
dataByDate: {},
|
||||
historyData: {
|
||||
allergy: { hasHistory: null, items: [] },
|
||||
disease: { hasHistory: null, items: [] },
|
||||
surgery: { hasHistory: null, items: [] },
|
||||
familyDisease: { hasHistory: null, items: [] },
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
@@ -82,6 +107,96 @@ const healthSlice = createSlice({
|
||||
state.error = 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,
|
||||
clearHealthDataForDate,
|
||||
clearAllHealthData,
|
||||
updateHistoryData,
|
||||
setHistoryData,
|
||||
clearHistoryData,
|
||||
} = healthSlice.actions;
|
||||
|
||||
// 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
|
||||
export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date];
|
||||
export const selectHealthLoading = (state: RootState) => state.health.loading;
|
||||
export const selectHealthError = (state: RootState) => state.health.error;
|
||||
export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime;
|
||||
export const selectHistoryData = (state: RootState) => state.health.historyData;
|
||||
|
||||
// 计算健康史完成度的 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;
|
||||
@@ -5,6 +5,7 @@ import challengesReducer from './challengesSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import circumferenceReducer from './circumferenceSlice';
|
||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||
import familyHealthReducer from './familyHealthSlice';
|
||||
import fastingReducer, {
|
||||
clearActiveSchedule,
|
||||
completeActiveSchedule,
|
||||
@@ -101,6 +102,7 @@ export const store = configureStore({
|
||||
checkin: checkinReducer,
|
||||
circumference: circumferenceReducer,
|
||||
health: healthReducer,
|
||||
familyHealth: familyHealthReducer,
|
||||
mood: moodReducer,
|
||||
nutrition: nutritionReducer,
|
||||
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