feat(medication): 重构AI分析为结构化展示并支持喝水提醒个性化配置

- 将药品AI分析从Markdown流式输出重构为结构化数据展示(V2)
- 新增适合人群、不适合人群、主要成分、副作用等分类卡片展示
- 优化AI分析UI布局,采用卡片式设计提升可读性
- 新增药品跳过功能,支持用户标记本次用药为已跳过
- 修复喝水提醒逻辑,支持用户开关控制和自定义时间段配置
- 优化个人资料编辑页面键盘适配,避免输入框被遮挡
- 统一API响应码处理,兼容200和0两种成功状态码
- 更新版本号至1.0.28

BREAKING CHANGE: 药品AI分析接口从流式Markdown输出改为结构化JSON格式,旧版本分析结果将不再显示
This commit is contained in:
richarjiang
2025-11-20 10:10:53 +08:00
parent b36922756d
commit 84abfa2506
12 changed files with 913 additions and 293 deletions

View File

@@ -23,6 +23,7 @@ import { createWaterRecordAction } from '@/store/waterSlice';
import { loadActiveFastingSchedule } from '@/utils/fasting'; import { loadActiveFastingSchedule } from '@/utils/fasting';
import { initializeHealthPermissions } from '@/utils/health'; import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native'; import { AppState, AppStateStatus } from 'react-native';
@@ -228,10 +229,23 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
logger.info('✅ 心情提醒已注册') logger.info('✅ 心情提醒已注册')
), ),
// 喝水提醒 // 喝水提醒 - 需要先检查设置
WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户').then(() => getWaterReminderSettings().then(settings => {
logger.info('✅ 喝水提醒已注册') if (settings.enabled) {
), // 如果使用的是自定义提醒scheduleCustomWaterReminders 会被调用(通常在设置页面保存时)
// 但为了保险起见,这里也可以根据设置类型来决定调用哪个
// 目前逻辑似乎是 scheduleRegularWaterReminders 是默认的/旧的逻辑?
// 查看 notificationHelpers.tsscheduleRegularWaterReminders 是每2小时一次的固定逻辑
// 而 scheduleCustomWaterReminders 是根据用户设置的时间段和间隔
// 如果用户开启了提醒,应该使用 scheduleCustomWaterReminders
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', settings).then(() =>
logger.info('✅ 自定义喝水提醒已注册')
);
} else {
logger.info(' 用户未开启喝水提醒,跳过注册');
}
}),
]); ]);
// 检查断食通知(如果有活跃计划) // 检查断食通知(如果有活跃计划)

View File

@@ -14,7 +14,7 @@ import { useI18n } from '@/hooks/useI18n';
import { useVipService } from '@/hooks/useVipService'; import { useVipService } from '@/hooks/useVipService';
import { medicationNotificationService } from '@/services/medicationNotifications'; import { medicationNotificationService } from '@/services/medicationNotifications';
import { import {
analyzeMedicationStream, analyzeMedicationV2,
getMedicationById, getMedicationById,
getMedicationRecords, getMedicationRecords,
} from '@/services/medications'; } from '@/services/medications';
@@ -24,7 +24,7 @@ import {
selectMedications, selectMedications,
updateMedicationAction, updateMedicationAction,
} from '@/store/medicationsSlice'; } from '@/store/medicationsSlice';
import type { Medication, MedicationForm } from '@/types/medication'; import type { Medication, MedicationAiAnalysisV2, MedicationForm } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Picker } from '@react-native-picker/picker'; import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice'; import Voice from '@react-native-voice/voice';
@@ -51,7 +51,6 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import ImageViewing from 'react-native-image-viewing'; import ImageViewing from 'react-native-image-viewing';
import Markdown from 'react-native-markdown-display';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png'); const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
@@ -108,8 +107,9 @@ export default function MedicationDetailScreen() {
// AI 分析相关状态 // AI 分析相关状态
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false); const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
const [aiAnalysisContent, setAiAnalysisContent] = useState(''); const [aiAnalysisResult, setAiAnalysisResult] = useState<MedicationAiAnalysisV2 | null>(null);
const [aiAnalysisAbortController, setAiAnalysisAbortController] = useState<AbortController | null>(null); const [aiAnalysisError, setAiAnalysisError] = useState<string | null>(null);
const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false);
// 剂量选择相关状态 // 剂量选择相关状态
const [dosagePickerVisible, setDosagePickerVisible] = useState(false); const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
@@ -135,13 +135,25 @@ export default function MedicationDetailScreen() {
} }
}, [dispatch, medicationFromStore]); }, [dispatch, medicationFromStore]);
useEffect(() => {
setAiAnalysisError(null);
}, [medicationId]);
useEffect(() => { useEffect(() => {
if (medicationFromStore) { if (medicationFromStore) {
setMedication(medicationFromStore); setMedication(medicationFromStore);
setLoading(false); setLoading(false);
setAiAnalysisResult(null);
// 如果服务端返回了 AI 分析结果,自动展示 // 如果服务端返回了 AI 分析结果,自动展示
if (medicationFromStore.aiAnalysis) { if (medicationFromStore.aiAnalysis) {
setAiAnalysisContent(medicationFromStore.aiAnalysis); try {
const parsed = JSON.parse(medicationFromStore.aiAnalysis);
if (parsed && typeof parsed === 'object') {
setAiAnalysisResult(parsed as MedicationAiAnalysisV2);
}
} catch {
// ignore legacy markdown
}
} }
} }
}, [medicationFromStore]); }, [medicationFromStore]);
@@ -201,8 +213,16 @@ export default function MedicationDetailScreen() {
setMedication(data); setMedication(data);
setError(null); setError(null);
// 如果服务端返回了 AI 分析结果,自动展示 // 如果服务端返回了 AI 分析结果,自动展示
setAiAnalysisResult(null);
if (data.aiAnalysis) { if (data.aiAnalysis) {
setAiAnalysisContent(data.aiAnalysis); try {
const parsed = JSON.parse(data.aiAnalysis);
if (parsed && typeof parsed === 'object') {
setAiAnalysisResult(parsed as MedicationAiAnalysisV2);
}
} catch {
// ignore legacy markdown
}
} }
}) })
.catch((err) => { .catch((err) => {
@@ -485,6 +505,13 @@ export default function MedicationDetailScreen() {
return `${t('medications.manage.frequency.custom')} · ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`; return `${t('medications.manage.frequency.custom')} · ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
} }
}, [medication, t]); }, [medication, t]);
const serviceInfo = useMemo(() => checkServiceAccess(), [checkServiceAccess]);
const hasAiAnalysis = Boolean(aiAnalysisResult);
const aiActionLabel = aiAnalysisLoading
? t('medications.detail.aiAnalysis.analyzingButton')
: hasAiAnalysis
? '重新分析'
: '获取 AI 分析';
const handleOpenNoteModal = useCallback(() => { const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? ''); setNoteDraft(medication?.note ?? '');
@@ -565,7 +592,12 @@ export default function MedicationDetailScreen() {
} }
}, [closeNoteModal, dispatch, medication, noteDraft]); }, [closeNoteModal, dispatch, medication, noteDraft]);
const statusLabel = medication?.isActive ? t('medications.detail.status.enabled') : t('medications.detail.status.disabled'); useEffect(() => {
if (serviceInfo.canUseService) {
setAiAnalysisLocked(false);
}
}, [serviceInfo.canUseService]);
const noteText = medication?.note?.trim() ? medication.note : t('medications.detail.note.noNote'); const noteText = medication?.note?.trim() ? medication.note : t('medications.detail.note.noNote');
const dayStreakText = const dayStreakText =
typeof summary.startedDays === 'number' typeof summary.startedDays === 'number'
@@ -802,6 +834,40 @@ export default function MedicationDetailScreen() {
}); });
}, [medication, router]); }, [medication, router]);
const renderAdviceCard = useCallback(
(
title: string,
items: string[] | undefined,
icon: keyof typeof Ionicons.glyphMap,
accentColor: string,
backgroundColor: string
) => {
if (!items?.length) return null;
return (
<View style={[styles.aiListCard, { backgroundColor, borderColor: backgroundColor }]}>
<View style={styles.aiListHeader}>
<View style={[styles.aiListIcon, { backgroundColor: `${accentColor}14` }]}>
<Ionicons name={icon} size={16} color={accentColor} />
</View>
<Text style={[styles.aiListTitle, { color: colors.text }]}>{title}</Text>
<View style={[styles.aiListCountBadge, { borderColor: `${accentColor}40` }]}>
<Text style={[styles.aiListCount, { color: accentColor }]}>{items.length}</Text>
</View>
</View>
<View style={styles.aiListContent}>
{items.map((item, index) => (
<View key={`${title}-${index}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: accentColor }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
</View>
);
},
[colors.text]
);
const confirmDosagePicker = useCallback(async () => { const confirmDosagePicker = useCallback(async () => {
if (!medication || updatePending) return; if (!medication || updatePending) return;
@@ -886,10 +952,9 @@ export default function MedicationDetailScreen() {
// 2. 检查用户是否是 VIP 或有剩余免费次数 // 2. 检查用户是否是 VIP 或有剩余免费次数
const serviceAccess = checkServiceAccess(); const serviceAccess = checkServiceAccess();
if (!serviceAccess.canUseService) { if (!serviceAccess.canUseService) {
// 如果不能使用服务,弹出会员购买弹窗 setAiAnalysisLocked(true);
openMembershipModal({ openMembershipModal({
onPurchaseSuccess: () => { onPurchaseSuccess: () => {
// 购买成功后自动执行 AI 分析
handleAiAnalysis(); handleAiAnalysis();
}, },
}); });
@@ -897,93 +962,45 @@ export default function MedicationDetailScreen() {
} }
// 3. 通过验证,执行 AI 分析 // 3. 通过验证,执行 AI 分析
// 重置状态
setAiAnalysisContent('');
setAiAnalysisLoading(true); setAiAnalysisLoading(true);
setAiAnalysisError(null);
setAiAnalysisLocked(false);
// 滚动到底部,让用户看到分析内容 // 滚动到底部,让用户看到分析内容
setTimeout(() => { setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true }); scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100); }, 100);
// 创建 AbortController 用于取消
const controller = new AbortController();
setAiAnalysisAbortController(controller);
try { try {
await analyzeMedicationStream( const result = await analyzeMedicationV2(medication.id);
medication.id, setAiAnalysisResult(result);
{ // 本地保存一份,便于下次打开快速展示
onChunk: (chunk: string) => { setMedication((prev) => (prev ? { ...prev, aiAnalysis: JSON.stringify(result) } : prev));
setAiAnalysisContent((prev) => prev + chunk); } catch (error: any) {
},
onEnd: () => {
setAiAnalysisLoading(false);
setAiAnalysisAbortController(null);
// 重新加载药品详情以获取最新的 aiAnalysis 字段
if (medicationId) {
getMedicationById(medicationId)
.then((data) => {
setMedication(data);
})
.catch((err) => {
console.error('重新加载药品详情失败', err);
});
}
},
onError: (error: any) => {
console.error('[MEDICATION] AI 分析失败:', error); console.error('[MEDICATION] AI 分析失败:', error);
const status = error?.status;
const message =
status === 403
? '免费使用次数已用完,请开通会员获取更多分析次数'
: status === 404
? '药品未找到'
: t('medications.detail.aiAnalysis.error.message');
let errorMessage = t('medications.detail.aiAnalysis.error.message'); setAiAnalysisError(message);
if (status === 403) {
// 解析服务端返回的错误信息 setAiAnalysisLocked(true);
if (error?.message) { openMembershipModal({
if (error.message.includes('[ERROR]')) { onPurchaseSuccess: () => {
errorMessage = error.message.replace('[ERROR]', '').trim(); handleAiAnalysis();
} else if (error.message.includes('无权访问')) {
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error.message.includes('不存在')) {
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
} else if (error?.status === 401) {
errorMessage = t('medications.detail.aiAnalysis.error.unauthorized');
} else if (error?.status === 403) {
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error?.status === 404) {
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
// 使用 Alert 弹窗显示错误
Alert.alert(t('medications.detail.aiAnalysis.error.title'), errorMessage);
// 清空内容和加载状态
setAiAnalysisContent('');
setAiAnalysisLoading(false);
setAiAnalysisAbortController(null);
}, },
});
} else {
Alert.alert(t('medications.detail.aiAnalysis.error.title'), message);
} }
); } finally {
} catch (error) {
console.error('[MEDICATION] AI 分析异常:', error);
// 使用 Alert 弹窗显示错误
Alert.alert(t('medications.detail.aiAnalysis.error.title'), t('medications.detail.aiAnalysis.error.networkError'));
// 清空内容和加载状态
setAiAnalysisContent('');
setAiAnalysisLoading(false); setAiAnalysisLoading(false);
setAiAnalysisAbortController(null);
} }
}, [medication, aiAnalysisLoading, ensureLoggedIn, checkServiceAccess, openMembershipModal]); }, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, medication, openMembershipModal, t]);
// 组件卸载时取消 AI 分析请求
useEffect(() => {
return () => {
if (aiAnalysisAbortController) {
aiAnalysisAbortController.abort();
}
};
}, [aiAnalysisAbortController]);
if (!medicationId) { if (!medicationId) {
return ( return (
@@ -1203,6 +1220,176 @@ export default function MedicationDetailScreen() {
</View> </View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} /> <Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</Section>
<Section title="AI 分析" color={colors.text}>
<View style={[styles.aiCardContainer, { backgroundColor: colors.surface }]}>
<View style={styles.aiHeaderRow}>
<View style={styles.aiHeaderLeft}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.aiHeaderTitle, { color: colors.text }]}></Text>
</View>
<View
style={[
styles.aiStatusPill,
{ backgroundColor: hasAiAnalysis ? '#E0F2FE' : aiAnalysisLocked ? '#FEF2F2' : '#EEF2FF' },
]}
>
<Ionicons
name={hasAiAnalysis ? 'checkmark-circle-outline' : aiAnalysisLocked ? 'lock-closed-outline' : 'flash-outline'}
size={15}
color={hasAiAnalysis ? '#22c55e' : aiAnalysisLocked ? '#ef4444' : colors.primary}
/>
<Text
style={[
styles.aiStatusText,
{ color: hasAiAnalysis ? '#16a34a' : aiAnalysisLocked ? '#ef4444' : colors.primary },
]}
>
{hasAiAnalysis ? '已生成' : aiAnalysisLocked ? '会员专享' : '待生成'}
</Text>
</View>
</View>
<View style={styles.aiHeroRow}>
<View style={styles.aiHeroImageWrapper}>
<View style={styles.aiHeroImageShadow}>
<Image
source={
photoPreview
? { uri: photoPreview }
: medication.photoUrl
? { uri: medication.photoUrl }
: DEFAULT_IMAGE
}
style={styles.aiHeroImage}
contentFit="cover"
/>
</View>
{hasAiAnalysis && (
<LinearGradient
colors={['#34d399', '#22c55e']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.aiScoreBadge}
>
<Ionicons name="thumbs-up-outline" size={14} color="#fff" />
<Text style={styles.aiScoreBadgeText}>AI </Text>
</LinearGradient>
)}
</View>
<View style={styles.aiHeroText}>
<Text style={[styles.aiHeroTitle, { color: colors.text }]} numberOfLines={2}>
{medication.name}
</Text>
<Text style={[styles.aiHeroSubtitle, { color: colors.textSecondary }]} numberOfLines={3}>
{aiAnalysisResult?.mainUsage || '获取 AI 分析,快速了解适用人群、成分安全与使用建议。'}
</Text>
<View style={styles.aiChipRow}>
<View style={styles.aiChip}>
<Ionicons name="medkit-outline" size={14} color={colors.primary} />
<Text style={[styles.aiChipText, { color: colors.text }]}>{dosageLabel}</Text>
</View>
<View style={styles.aiChip}>
<Ionicons name="cube-outline" size={14} color={colors.primary} />
<Text style={[styles.aiChipText, { color: colors.text }]}>{formLabel}</Text>
</View>
</View>
</View>
</View>
{aiAnalysisLoading && (
<View style={styles.aiLoadingRow}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.aiLoadingText, { color: colors.textSecondary }]}>
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
</View>
)}
{aiAnalysisResult ? (
<>
{!!aiAnalysisResult.mainIngredients?.length && (
<View style={styles.aiTagRow}>
{aiAnalysisResult.mainIngredients.map((item) => (
<View key={item} style={styles.aiTag}>
<Text style={[styles.aiTagText, { color: colors.text }]} numberOfLines={1}>
{item}
</Text>
</View>
))}
</View>
)}
<View style={styles.aiUsageCard}>
<Ionicons name="leaf-outline" size={18} color="#22c55e" />
<Text style={[styles.aiUsageText, { color: colors.text }]}>{aiAnalysisResult.mainUsage}</Text>
</View>
<View style={styles.aiColumns}>
<View style={[styles.aiBubbleCard, { backgroundColor: '#ECFEFF', borderColor: '#BAF2F4' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}></Text>
<Ionicons name="checkmark-circle" size={16} color="#0ea5e9" />
</View>
{aiAnalysisResult.suitableFor.map((item, idx) => (
<View key={`s-${idx}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: '#0ea5e9' }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.aiBubbleCard, { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}></Text>
<Ionicons name="alert-circle" size={16} color="#ef4444" />
</View>
{aiAnalysisResult.unsuitableFor.map((item, idx) => (
<View key={`u-${idx}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: '#ef4444' }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
</View>
{renderAdviceCard('可能的副作用', aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')}
{renderAdviceCard('储存建议', aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')}
{renderAdviceCard('健康/使用建议', aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')}
</>
) : null}
{aiAnalysisError && (
<View style={styles.aiErrorBox}>
<Ionicons name="alert-circle-outline" size={18} color="#ef4444" />
<Text style={[styles.aiErrorText, { color: colors.text }]}>{aiAnalysisError}</Text>
</View>
)}
{(aiAnalysisLocked || !serviceInfo.canUseService) && (
<TouchableOpacity
activeOpacity={0.9}
onPress={() =>
openMembershipModal({
onPurchaseSuccess: () => {
handleAiAnalysis();
},
})
}
style={styles.aiMembershipCard}
>
<View style={styles.aiMembershipLeft}>
<Ionicons name="diamond-outline" size={18} color="#f59e0b" />
<View>
<Text style={styles.aiMembershipTitle}> AI </Text>
<Text style={styles.aiMembershipSub}>使</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#f59e0b" />
</TouchableOpacity>
)}
</View>
</Section> </Section>
<Section title={t('medications.detail.sections.overview')} color={colors.text}> <Section title={t('medications.detail.sections.overview')} color={colors.text}>
@@ -1221,103 +1408,7 @@ export default function MedicationDetailScreen() {
</View> </View>
</Section> </Section>
{/* AI 分析结果展示 - 移动到底部 */}
{(aiAnalysisContent || aiAnalysisLoading) && (
<Section title={t('medications.detail.sections.aiAnalysis')} color={colors.text}>
<View style={[styles.aiAnalysisCard, { backgroundColor: colors.surface }]}>
{aiAnalysisLoading && !aiAnalysisContent && (
<View style={styles.aiAnalysisLoading}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.aiAnalysisLoadingText, { color: colors.textSecondary }]}>
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
</View>
)}
{aiAnalysisContent && (
<View style={styles.aiAnalysisContentWrapper}>
<Markdown
style={{
body: { color: colors.text, fontSize: 15, lineHeight: 24 },
paragraph: { marginTop: 2, marginBottom: 8 },
bullet_list: { marginVertical: 8 },
ordered_list: { marginVertical: 8 },
list_item: { flexDirection: 'row', marginVertical: 2 },
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 8, color: colors.text },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 8, color: colors.text },
heading3: { fontSize: 16, fontWeight: '700', marginVertical: 6, color: colors.text },
heading4: { fontSize: 15, fontWeight: '700', marginVertical: 4, color: colors.text },
strong: { fontWeight: '700', color: colors.text },
em: { fontStyle: 'italic' },
link: { color: colors.primary, textDecorationLine: 'underline' },
blockquote: {
backgroundColor: 'rgba(0,0,0,0.03)',
borderLeftWidth: 4,
borderLeftColor: colors.primary,
paddingHorizontal: 12,
paddingVertical: 8,
marginVertical: 8,
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
table: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
marginVertical: 8,
},
thead: {
backgroundColor: 'rgba(0,0,0,0.03)',
},
tr: {
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
th: {
padding: 8,
fontWeight: '700',
},
td: {
padding: 8,
},
}}
>
{aiAnalysisContent}
</Markdown>
{aiAnalysisLoading && (
<View style={styles.aiAnalysisStreaming}>
<ActivityIndicator color={colors.primary} size="small" />
</View>
)}
</View>
)}
</View>
</Section>
)}
</ScrollView> </ScrollView>
) : null} ) : null}
@@ -1331,7 +1422,8 @@ export default function MedicationDetailScreen() {
]} ]}
> >
<View style={styles.footerButtonContainer}> <View style={styles.footerButtonContainer}>
{/* AI 分析按钮 - 占 2/3 宽度 */} {/* AI 分析按钮 */}
{!hasAiAnalysis && (
<TouchableOpacity <TouchableOpacity
style={styles.aiAnalysisButtonWrapper} style={styles.aiAnalysisButtonWrapper}
activeOpacity={0.9} activeOpacity={0.9}
@@ -1351,7 +1443,7 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" /> <Ionicons name="sparkles-outline" size={18} color="#fff" />
)} )}
<Text style={styles.aiAnalysisButtonText}> <Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')} {aiActionLabel}
</Text> </Text>
</GlassView> </GlassView>
) : ( ) : (
@@ -1362,13 +1454,14 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" /> <Ionicons name="sparkles-outline" size={18} color="#fff" />
)} )}
<Text style={styles.aiAnalysisButtonText}> <Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')} {aiActionLabel}
</Text> </Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
)}
{/* 删除按钮 - 占 1/3 宽度 */} {/* 删除按钮 */}
<TouchableOpacity <TouchableOpacity
style={styles.deleteButtonWrapper} style={styles.deleteButtonWrapper}
activeOpacity={0.9} activeOpacity={0.9}
@@ -1381,11 +1474,11 @@ export default function MedicationDetailScreen() {
tintColor="rgba(239, 68, 68, 0.8)" tintColor="rgba(239, 68, 68, 0.8)"
isInteractive={true} isInteractive={true}
> >
<Ionicons name="trash-outline" size={18} color="#fff" /> <Ionicons name="trash-outline" size={24} color="#fff" />
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}> <View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
<Ionicons name="trash-outline" size={18} color="#fff" /> <Ionicons name="trash-outline" size={24} color="#fff" />
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -2095,12 +2188,11 @@ const styles = StyleSheet.create({
borderTopColor: 'rgba(15,23,42,0.06)', borderTopColor: 'rgba(15,23,42,0.06)',
}, },
deleteButton: { deleteButton: {
width: 56,
height: 56, height: 56,
borderRadius: 24, borderRadius: 28,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 8,
overflow: 'hidden', // 保证玻璃边界圆角效果 overflow: 'hidden', // 保证玻璃边界圆角效果
}, },
fallbackDeleteButton: { fallbackDeleteButton: {
@@ -2115,12 +2207,13 @@ const styles = StyleSheet.create({
footerButtonContainer: { footerButtonContainer: {
flexDirection: 'row', flexDirection: 'row',
gap: 12, gap: 12,
justifyContent: 'flex-end',
}, },
aiAnalysisButtonWrapper: { aiAnalysisButtonWrapper: {
flex: 2, // 占 2/3 宽度 flex: 1,
}, },
deleteButtonWrapper: { deleteButtonWrapper: {
flex: 1, // 占 1/3 宽度 // auto width
}, },
aiAnalysisButton: { aiAnalysisButton: {
height: 56, height: 56,
@@ -2145,10 +2238,10 @@ const styles = StyleSheet.create({
color: '#fff', color: '#fff',
}, },
// AI 分析卡片样式 // AI 分析卡片样式
aiAnalysisCard: { aiCardContainer: {
borderRadius: 24, borderRadius: 26,
padding: 20, padding: 20,
minHeight: 100, gap: 14,
shadowColor: '#000', shadowColor: '#000',
shadowOpacity: 0.06, shadowOpacity: 0.06,
shadowRadius: 12, shadowRadius: 12,
@@ -2157,31 +2250,298 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.04)', borderColor: 'rgba(0, 0, 0, 0.04)',
}, },
aiAnalysisLoading: { aiHeaderRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 12, justifyContent: 'space-between',
paddingVertical: 12,
}, },
aiAnalysisLoadingText: { aiHeaderLeft: {
fontSize: 15, flexDirection: 'row',
alignItems: 'center',
gap: 8,
}, },
aiAnalysisContentWrapper: { aiHeaderTitle: {
gap: 12,
},
aiAnalysisText: {
fontSize: 15,
lineHeight: 24,
},
aiAnalysisStreaming: {
alignSelf: 'flex-start',
marginTop: 8,
},
deleteButtonText: {
fontSize: 17, fontSize: 17,
fontWeight: '700', fontWeight: '700',
},
aiStatusPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
aiStatusText: {
fontSize: 12,
fontWeight: '700',
},
aiLoadingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
aiLoadingText: {
fontSize: 13,
},
aiHeroRow: {
flexDirection: 'row',
gap: 14,
alignItems: 'center',
},
aiHeroImageWrapper: {
width: 110,
height: 110,
borderRadius: 28,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
aiHeroImageShadow: {
width: 90,
height: 90,
borderRadius: 22,
overflow: 'hidden',
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
},
aiHeroImage: {
width: '100%',
height: '100%',
borderRadius: 22,
},
aiScoreBadge: {
position: 'absolute',
bottom: -6,
right: -4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
shadowColor: '#22c55e',
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
aiScoreBadgeText: {
fontSize: 12,
fontWeight: '700',
color: '#fff', color: '#fff',
}, },
aiHeroText: {
flex: 1,
gap: 8,
},
aiHeroTitle: {
fontSize: 20,
fontWeight: '800',
},
aiHeroSubtitle: {
fontSize: 14,
lineHeight: 20,
},
aiChipRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
aiChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: '#F3F4F6',
borderRadius: 12,
},
aiChipText: {
fontSize: 13,
fontWeight: '600',
},
aiTagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
aiTag: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
backgroundColor: '#F2FCE2',
},
aiTagText: {
fontSize: 13,
fontWeight: '600',
},
aiUsageCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 14,
borderRadius: 18,
backgroundColor: '#F8FAFC',
borderWidth: 1,
borderColor: 'rgba(15,23,42,0.05)',
},
aiUsageText: {
fontSize: 15,
lineHeight: 22,
flex: 1,
},
aiColumns: {
flexDirection: 'row',
gap: 12,
flexWrap: 'wrap',
},
aiBubbleCard: {
flex: 1,
borderWidth: 1,
borderRadius: 18,
padding: 14,
gap: 8,
},
aiBubbleHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
aiBubbleTitle: {
fontSize: 15,
fontWeight: '700',
},
aiListCard: {
borderWidth: 1,
borderRadius: 18,
padding: 14,
gap: 12,
},
aiListHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
aiListIcon: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
aiListTitle: {
fontSize: 15,
fontWeight: '700',
flex: 1,
},
aiListCountBadge: {
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 2,
},
aiListCount: {
fontSize: 12,
fontWeight: '700',
},
aiListContent: {
gap: 8,
},
aiBulletRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
},
aiBulletDot: {
width: 8,
height: 8,
borderRadius: 4,
marginTop: 6,
},
aiBulletText: {
fontSize: 14,
lineHeight: 22,
flex: 1,
},
aiEmptyBox: {
borderRadius: 18,
padding: 16,
backgroundColor: '#F8FAFF',
borderWidth: 1,
borderColor: '#E0EAFF',
gap: 8,
alignItems: 'center',
},
aiEmptyTitle: {
fontSize: 16,
fontWeight: '700',
},
aiEmptyDesc: {
fontSize: 14,
lineHeight: 22,
textAlign: 'center',
},
aiFreeBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#EEF2FF',
borderRadius: 12,
},
aiFreeBadgeText: {
fontSize: 12,
fontWeight: '700',
},
aiErrorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
borderRadius: 14,
backgroundColor: '#FEF2F2',
borderWidth: 1,
borderColor: '#FEE2E2',
},
aiErrorText: {
fontSize: 14,
lineHeight: 20,
flex: 1,
},
aiMembershipCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 14,
borderRadius: 18,
backgroundColor: '#FFFBEB',
borderWidth: 1,
borderColor: '#FDE68A',
},
aiMembershipLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flex: 1,
},
aiMembershipTitle: {
fontSize: 15,
fontWeight: '700',
color: '#92400e',
},
aiMembershipSub: {
fontSize: 13,
color: '#b45309',
marginTop: 2,
},
// Picker 相关样式 // Picker 相关样式
pickerBackdrop: { pickerBackdrop: {
flex: 1, flex: 1,

View File

@@ -20,6 +20,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Modal, Modal,
Platform, Platform,
@@ -31,6 +32,7 @@ import {
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface UserProfile { interface UserProfile {
@@ -81,7 +83,8 @@ export default function EditProfileScreen() {
const [editingField, setEditingField] = useState<string | null>(null); const [editingField, setEditingField] = useState<string | null>(null);
const [tempValue, setTempValue] = useState<string>(''); const [tempValue, setTempValue] = useState<string>('');
// 输入框字符串 // 键盘高度状态
const [keyboardHeight, setKeyboardHeight] = useState(0);
// 从本地存储加载(身高/体重等本地字段) // 从本地存储加载(身高/体重等本地字段)
const loadLocalProfile = async () => { const loadLocalProfile = async () => {
@@ -128,6 +131,34 @@ export default function EditProfileScreen() {
loadLocalProfile(); loadLocalProfile();
}, []); }, []);
// 键盘事件监听器 - 只在名称和体重输入框显示时监听
useEffect(() => {
// 只有在编辑名称或体重字段时才需要监听键盘(这两个字段使用 TextInput
const needsKeyboardHandling = editingField === 'name' || editingField === 'weight';
if (!needsKeyboardHandling) {
setKeyboardHeight(0);
return;
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleShow = (event: any) => {
const height = event?.endCoordinates?.height ?? 0;
setKeyboardHeight(height);
};
const handleHide = () => setKeyboardHeight(0);
const showSub = Keyboard.addListener(showEvent, handleShow);
const hideSub = Keyboard.addListener(hideEvent, handleHide);
return () => {
showSub.remove();
hideSub.remove();
};
}, [editingField]);
// 获取最大心率数据 // 获取最大心率数据
useEffect(() => { useEffect(() => {
const loadMaximumHeartRate = async () => { const loadMaximumHeartRate = async () => {
@@ -439,6 +470,7 @@ export default function EditProfileScreen() {
field={editingField} field={editingField}
value={tempValue} value={tempValue}
profile={profile} profile={profile}
keyboardHeight={keyboardHeight}
onClose={() => { onClose={() => {
setEditingField(null); setEditingField(null);
setTempValue(''); setTempValue('');
@@ -557,11 +589,12 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
); );
} }
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor, t }: { function EditModal({ visible, field, value, profile, keyboardHeight, onClose, onSave, colors, textColor, placeholderColor, t }: {
visible: boolean; visible: boolean;
field: string | null; field: string | null;
value: string; value: string;
profile: UserProfile; profile: UserProfile;
keyboardHeight: number;
onClose: () => void; onClose: () => void;
onSave: (field: string, value: string) => void; onSave: (field: string, value: string) => void;
colors: any; colors: any;
@@ -569,6 +602,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
placeholderColor: string; placeholderColor: string;
t: (key: string) => string; t: (key: string) => string;
}) { }) {
const insets = useSafeAreaInsets();
const [inputValue, setInputValue] = useState(value); const [inputValue, setInputValue] = useState(value);
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female'); const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1); const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
@@ -685,7 +719,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose} /> <Pressable style={styles.modalBackdrop} onPress={onClose} />
<View style={styles.editModalSheet}> <View style={[
styles.editModalSheet,
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 }
]}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
{renderContent()} {renderContent()}
<View style={styles.modalButtons}> <View style={styles.modalButtons}>

View File

@@ -1,7 +1,7 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { takeMedicationAction } from '@/store/medicationsSlice'; import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
import type { MedicationDisplayItem } from '@/types/medication'; import type { MedicationDisplayItem } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
@@ -100,6 +100,64 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
} }
}; };
/**
* 处理跳过操作
*/
const handleSkipMedication = async () => {
// 检查 recordId 是否存在
if (!medication.recordId || isSubmitting) {
return;
}
// 显示二次确认弹窗
Alert.alert(
t('medications.card.skipAlert.title'),
t('medications.card.skipAlert.message'),
[
{
text: t('medications.card.skipAlert.cancel'),
style: 'cancel',
onPress: () => {
console.log('用户取消跳过');
},
},
{
text: t('medications.card.skipAlert.confirm'),
style: 'destructive',
onPress: () => {
executeSkipMedication(medication.recordId!);
},
},
]
);
};
/**
* 执行跳过操作
*/
const executeSkipMedication = async (recordId: string) => {
setIsSubmitting(true);
try {
// 调用 Redux action 标记为已跳过
await dispatch(skipMedicationAction({
recordId: recordId,
})).unwrap();
// 可选:显示成功提示
// Alert.alert('跳过成功', '已跳过本次用药');
} catch (error) {
console.error('[MEDICATION_CARD] 跳过操作失败', error);
Alert.alert(
t('medications.card.skipError.title'),
error instanceof Error ? error.message : t('medications.card.skipError.message'),
[{ text: t('medications.card.skipError.confirm') }]
);
} finally {
setIsSubmitting(false);
}
};
const renderStatusBadge = () => { const renderStatusBadge = () => {
if (medication.status === 'missed') { if (medication.status === 'missed') {
return ( return (
@@ -136,6 +194,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
}; };
const renderAction = () => { const renderAction = () => {
// 已服用状态
if (medication.status === 'taken') { if (medication.status === 'taken') {
return ( return (
<View style={[styles.actionButton, styles.actionButtonTaken]}> <View style={[styles.actionButton, styles.actionButtonTaken]}>
@@ -145,12 +204,52 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
); );
} }
// 只要没有服药,都可以显示立即服用 // 已跳过状态
if (medication.status === 'skipped') {
return ( return (
<View style={[styles.actionButton, styles.actionButtonSkipped]}>
<Ionicons name="close-circle" size={18} color="#fff" />
<ThemedText style={styles.actionButtonText}>{t('medications.card.action.skipped')}</ThemedText>
</View>
);
}
// 待服用或已错过状态,显示操作按钮
return (
<View style={styles.actionButtonsRow}>
{/* 跳过按钮 */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleSkipMedication}
disabled={isSubmitting}
style={styles.skipButtonWrapper}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonSkip]}
glassEffectStyle="clear"
tintColor="rgba(156, 163, 175, 0.2)"
isInteractive={!isSubmitting}
>
<ThemedText style={styles.actionButtonTextSkip}>
{t('medications.card.action.skip')}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonSkip, styles.fallbackActionButtonSkip]}>
<ThemedText style={styles.actionButtonTextSkip}>
{t('medications.card.action.skip')}
</ThemedText>
</View>
)}
</TouchableOpacity>
{/* 立即服用按钮 */}
<TouchableOpacity <TouchableOpacity
activeOpacity={0.7} activeOpacity={0.7}
onPress={handleTakeMedication} onPress={handleTakeMedication}
disabled={isSubmitting} disabled={isSubmitting}
style={styles.takeButtonWrapper}
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
<GlassView <GlassView
@@ -171,6 +270,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
); );
}; };
@@ -286,6 +386,16 @@ const styles = StyleSheet.create({
actionContainer: { actionContainer: {
marginTop: 8, marginTop: 8,
}, },
actionButtonsRow: {
flexDirection: 'row',
gap: 8,
},
skipButtonWrapper: {
flex: 1,
},
takeButtonWrapper: {
flex: 2,
},
actionButton: { actionButton: {
alignSelf: 'stretch', alignSelf: 'stretch',
flexDirection: 'row', flexDirection: 'row',
@@ -302,6 +412,12 @@ const styles = StyleSheet.create({
actionButtonTaken: { actionButtonTaken: {
backgroundColor: '#1FBF4B', backgroundColor: '#1FBF4B',
}, },
actionButtonSkipped: {
backgroundColor: '#9CA3AF',
},
actionButtonSkip: {
backgroundColor: '#E5E7EB',
},
actionButtonMissed: { actionButtonMissed: {
backgroundColor: '#9CA3AF', backgroundColor: '#9CA3AF',
}, },
@@ -310,6 +426,11 @@ const styles = StyleSheet.create({
borderColor: 'rgba(19, 99, 255, 0.3)', borderColor: 'rgba(19, 99, 255, 0.3)',
backgroundColor: 'rgba(19, 99, 255, 0.9)', backgroundColor: 'rgba(19, 99, 255, 0.9)',
}, },
fallbackActionButtonSkip: {
borderWidth: 1,
borderColor: 'rgba(156, 163, 175, 0.2)',
backgroundColor: 'rgba(229, 231, 235, 0.9)',
},
fallbackActionButtonMissed: { fallbackActionButtonMissed: {
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(156, 163, 175, 0.3)', borderColor: 'rgba(156, 163, 175, 0.3)',
@@ -320,6 +441,11 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: '#fff', color: '#fff',
}, },
actionButtonTextSkip: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
},
actionButtonTextMissed: { actionButtonTextMissed: {
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',

View File

@@ -472,8 +472,16 @@ const medicationsResources = {
action: { action: {
takeNow: '立即服用', takeNow: '立即服用',
taken: '已服用', taken: '已服用',
skipped: '已跳过',
skip: '跳过',
submitting: '提交中...', submitting: '提交中...',
}, },
skipAlert: {
title: '确认跳过',
message: '确定要跳过本次用药吗?\n\n跳过后将不会记录为已服用。',
cancel: '取消',
confirm: '确认跳过',
},
earlyTakeAlert: { earlyTakeAlert: {
title: '尚未到服药时间', title: '尚未到服药时间',
message: '该用药计划在 {{time}}现在还早于1小时以上。\n\n是否确认已服用此药物', message: '该用药计划在 {{time}}现在还早于1小时以上。\n\n是否确认已服用此药物',
@@ -485,6 +493,11 @@ const medicationsResources = {
message: '记录服药时发生错误,请稍后重试', message: '记录服药时发生错误,请稍后重试',
confirm: '确定', confirm: '确定',
}, },
skipError: {
title: '操作失败',
message: '跳过操作失败,请稍后重试',
confirm: '确定',
},
}, },
// 添加药物页面翻译 // 添加药物页面翻译
add: { add: {
@@ -1225,8 +1238,16 @@ const resources = {
action: { action: {
takeNow: 'Take Now', takeNow: 'Take Now',
taken: 'Taken', taken: 'Taken',
skipped: 'Skipped',
skip: 'Skip',
submitting: 'Submitting...', submitting: 'Submitting...',
}, },
skipAlert: {
title: 'Confirm Skip',
message: 'Are you sure you want to skip this medication?\n\nIt will not be recorded as taken.',
cancel: 'Cancel',
confirm: 'Confirm Skip',
},
earlyTakeAlert: { earlyTakeAlert: {
title: 'Not yet time to take medication', title: 'Not yet time to take medication',
message: 'This medication is scheduled for {{time}}, which is more than 1 hour from now.\n\nHave you already taken this medication?', message: 'This medication is scheduled for {{time}}, which is more than 1 hour from now.\n\nHave you already taken this medication?',
@@ -1238,6 +1259,11 @@ const resources = {
message: 'An error occurred while recording medication, please try again later', message: 'An error occurred while recording medication, please try again later',
confirm: 'OK', confirm: 'OK',
}, },
skipError: {
title: 'Operation Failed',
message: 'Skip operation failed, please try again later',
confirm: 'OK',
},
}, },
// 添加药物页面翻译 // 添加药物页面翻译
add: { add: {

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.27</string> <string>1.0.28</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -144,7 +144,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
throw error; throw error;
} }
if (json.code !== undefined && json.code !== 0) { if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`; const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
const error = new Error(errorMessage); const error = new Error(errorMessage);
// @ts-expect-error augment // @ts-expect-error augment
@@ -324,4 +324,3 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr
return { abort, requestId }; return { abort, requestId };
} }

View File

@@ -1,12 +1,12 @@
import { listChallenges } from '@/services/challengesApi'; import { listChallenges } from '@/services/challengesApi';
import { resyncFastingNotifications } from '@/services/fastingNotifications';
import { store } from '@/store'; import { store } from '@/store';
import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice';
import { getWaterIntakeFromHealthKit } from '@/utils/health'; import { getWaterIntakeFromHealthKit } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
import { log } from '@/utils/logger'; import { log } from '@/utils/logger';
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getWaterGoalFromStorage } from '@/utils/userPreferences'; import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences';
import { resyncFastingNotifications } from '@/services/fastingNotifications';
import { selectActiveFastingSchedule, selectActiveFastingPlan } from '@/store/fastingSlice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as BackgroundTask from 'expo-background-task'; import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager'; import * as TaskManager from 'expo-task-manager';
@@ -33,6 +33,13 @@ async function executeWaterReminderTask(): Promise<void> {
try { try {
console.log('执行喝水提醒后台任务...'); console.log('执行喝水提醒后台任务...');
// 检查是否开启了喝水提醒
const isEnabled = await getWaterReminderEnabled();
if (!isEnabled) {
console.log('喝水提醒未开启,跳过后台任务');
return;
}
// 获取当前状态,添加错误处理 // 获取当前状态,添加错误处理
let state; let state;
try { try {

View File

@@ -8,7 +8,7 @@ import { getWaterIntakeFromHealthKit } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getWaterGoalFromStorage } from '@/utils/userPreferences'; import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -39,6 +39,13 @@ async function executeWaterReminderTask(): Promise<void> {
try { try {
console.log('执行喝水提醒后台任务...'); console.log('执行喝水提醒后台任务...');
// 检查是否开启了喝水提醒
const isEnabled = await getWaterReminderEnabled();
if (!isEnabled) {
console.log('喝水提醒未开启,跳过后台任务');
return;
}
let state; let state;
try { try {
state = store.getState(); state = store.getState();

View File

@@ -5,6 +5,7 @@
import type { import type {
DailyMedicationStats, DailyMedicationStats,
Medication, Medication,
MedicationAiAnalysisV2,
MedicationForm, MedicationForm,
MedicationRecord, MedicationRecord,
MedicationStatus, MedicationStatus,
@@ -329,3 +330,17 @@ export async function analyzeMedicationStream(
{ timeoutMs: 120000 } { timeoutMs: 120000 }
); );
} }
/**
* 获取药品 AI 分析 V2 结构化报告
* @param medicationId 药品 ID
* @returns 结构化 AI 分析结果
*/
export async function analyzeMedicationV2(
medicationId: string
): Promise<MedicationAiAnalysisV2> {
return api.post<MedicationAiAnalysisV2>(
`/api/medications/${medicationId}/ai-analysis/v2`,
{}
);
}

View File

@@ -92,3 +92,16 @@ export interface MedicationDisplayItem {
recordId?: string; // 服药记录ID用于更新状态 recordId?: string; // 服药记录ID用于更新状态
medicationId: string; // 药物ID medicationId: string; // 药物ID
} }
/**
* 药品 AI 分析 V2 结构化数据
*/
export interface MedicationAiAnalysisV2 {
suitableFor: string[]; // 适合人群
unsuitableFor: string[]; // 不适合人群/慎用
mainIngredients: string[]; // 主要成分
mainUsage: string; // 主要用途/功效
sideEffects: string[]; // 常见副作用
storageAdvice: string[]; // 储存建议
healthAdvice: string[]; // 健康建议/使用建议
}

View File

@@ -1,6 +1,6 @@
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { NotificationData, NotificationTypes, notificationService } from '../services/notifications'; import { NotificationData, NotificationTypes, notificationService } from '../services/notifications';
import { getNotificationEnabled } from './userPreferences'; import { getNotificationEnabled, getWaterReminderEnabled } from './userPreferences';
/** /**
* 构建 coach 页面的深度链接 * 构建 coach 页面的深度链接
@@ -433,6 +433,13 @@ export class WaterNotificationHelpers {
currentHour: number = new Date().getHours() currentHour: number = new Date().getHours()
): Promise<boolean> { ): Promise<boolean> {
try { try {
// 首先检查用户是否启用了喝水提醒
const isWaterReminderEnabled = await getWaterReminderEnabled();
if (!isWaterReminderEnabled) {
console.log('用户未启用喝水提醒,跳过通知检查');
return false;
}
// 检查时间限制早上9点以前和晚上9点以后不通知 // 检查时间限制早上9点以前和晚上9点以后不通知
if (currentHour < 9 || currentHour >= 23) { if (currentHour < 9 || currentHour >= 23) {
console.log(`当前时间${currentHour}不在通知时间范围内9:00-21:00跳过喝水提醒`); console.log(`当前时间${currentHour}不在通知时间范围内9:00-21:00跳过喝水提醒`);
@@ -546,6 +553,15 @@ export class WaterNotificationHelpers {
*/ */
static async scheduleRegularWaterReminders(userName: string): Promise<string[]> { static async scheduleRegularWaterReminders(userName: string): Promise<string[]> {
try { try {
// 首先检查用户是否启用了喝水提醒
const isWaterReminderEnabled = await getWaterReminderEnabled();
if (!isWaterReminderEnabled) {
console.log('用户未启用喝水提醒,不安排定期提醒');
// 确保取消任何可能存在的旧提醒
await this.cancelAllWaterReminders();
return [];
}
const notificationIds: string[] = []; const notificationIds: string[] = [];
// 检查是否已经存在定期喝水提醒 // 检查是否已经存在定期喝水提醒