From 84abfa25066d97f312d7ce9ad506714ae79f4d6a Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 20 Nov 2025 10:10:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(medication):=20=E9=87=8D=E6=9E=84AI?= =?UTF-8?q?=E5=88=86=E6=9E=90=E4=B8=BA=E7=BB=93=E6=9E=84=E5=8C=96=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=B9=B6=E6=94=AF=E6=8C=81=E5=96=9D=E6=B0=B4=E6=8F=90?= =?UTF-8?q?=E9=86=92=E4=B8=AA=E6=80=A7=E5=8C=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将药品AI分析从Markdown流式输出重构为结构化数据展示(V2) - 新增适合人群、不适合人群、主要成分、副作用等分类卡片展示 - 优化AI分析UI布局,采用卡片式设计提升可读性 - 新增药品跳过功能,支持用户标记本次用药为已跳过 - 修复喝水提醒逻辑,支持用户开关控制和自定义时间段配置 - 优化个人资料编辑页面键盘适配,避免输入框被遮挡 - 统一API响应码处理,兼容200和0两种成功状态码 - 更新版本号至1.0.28 BREAKING CHANGE: 药品AI分析接口从流式Markdown输出改为结构化JSON格式,旧版本分析结果将不再显示 --- app/_layout.tsx | 22 +- app/medications/[medicationId].tsx | 858 ++++++++++++++++------- app/profile/edit.tsx | 45 +- components/medication/MedicationCard.tsx | 178 ++++- i18n/index.ts | 26 + ios/OutLive/Info.plist | 2 +- services/api.ts | 3 +- services/backgroundTaskManager.ts | 13 +- services/backgroundTaskManagerV2.ts | 9 +- services/medications.ts | 17 +- types/medication.ts | 15 +- utils/notificationHelpers.ts | 18 +- 12 files changed, 913 insertions(+), 293 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 264786a..436cc66 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -23,6 +23,7 @@ import { createWaterRecordAction } from '@/store/waterSlice'; import { loadActiveFastingSchedule } from '@/utils/fasting'; import { initializeHealthPermissions } from '@/utils/health'; import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; +import { getWaterReminderSettings } from '@/utils/userPreferences'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React, { useEffect } from 'react'; import { AppState, AppStateStatus } from 'react-native'; @@ -228,10 +229,23 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { logger.info('✅ 心情提醒已注册') ), - // 喝水提醒 - WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户').then(() => - logger.info('✅ 喝水提醒已注册') - ), + // 喝水提醒 - 需要先检查设置 + getWaterReminderSettings().then(settings => { + if (settings.enabled) { + // 如果使用的是自定义提醒,scheduleCustomWaterReminders 会被调用(通常在设置页面保存时) + // 但为了保险起见,这里也可以根据设置类型来决定调用哪个 + // 目前逻辑似乎是 scheduleRegularWaterReminders 是默认的/旧的逻辑? + // 查看 notificationHelpers.ts,scheduleRegularWaterReminders 是每2小时一次的固定逻辑 + // 而 scheduleCustomWaterReminders 是根据用户设置的时间段和间隔 + + // 如果用户开启了提醒,应该使用 scheduleCustomWaterReminders + WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', settings).then(() => + logger.info('✅ 自定义喝水提醒已注册') + ); + } else { + logger.info('ℹ️ 用户未开启喝水提醒,跳过注册'); + } + }), ]); // 检查断食通知(如果有活跃计划) diff --git a/app/medications/[medicationId].tsx b/app/medications/[medicationId].tsx index 4588c72..f537695 100644 --- a/app/medications/[medicationId].tsx +++ b/app/medications/[medicationId].tsx @@ -14,7 +14,7 @@ import { useI18n } from '@/hooks/useI18n'; import { useVipService } from '@/hooks/useVipService'; import { medicationNotificationService } from '@/services/medicationNotifications'; import { - analyzeMedicationStream, + analyzeMedicationV2, getMedicationById, getMedicationRecords, } from '@/services/medications'; @@ -24,7 +24,7 @@ import { selectMedications, updateMedicationAction, } 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 { Picker } from '@react-native-picker/picker'; import Voice from '@react-native-voice/voice'; @@ -51,7 +51,6 @@ import { View, } from 'react-native'; import ImageViewing from 'react-native-image-viewing'; -import Markdown from 'react-native-markdown-display'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png'); @@ -108,8 +107,9 @@ export default function MedicationDetailScreen() { // AI 分析相关状态 const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false); - const [aiAnalysisContent, setAiAnalysisContent] = useState(''); - const [aiAnalysisAbortController, setAiAnalysisAbortController] = useState(null); + const [aiAnalysisResult, setAiAnalysisResult] = useState(null); + const [aiAnalysisError, setAiAnalysisError] = useState(null); + const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false); // 剂量选择相关状态 const [dosagePickerVisible, setDosagePickerVisible] = useState(false); @@ -135,13 +135,25 @@ export default function MedicationDetailScreen() { } }, [dispatch, medicationFromStore]); + useEffect(() => { + setAiAnalysisError(null); + }, [medicationId]); + useEffect(() => { if (medicationFromStore) { setMedication(medicationFromStore); setLoading(false); + setAiAnalysisResult(null); // 如果服务端返回了 AI 分析结果,自动展示 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]); @@ -201,8 +213,16 @@ export default function MedicationDetailScreen() { setMedication(data); setError(null); // 如果服务端返回了 AI 分析结果,自动展示 + setAiAnalysisResult(null); 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) => { @@ -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]}`; } }, [medication, t]); + const serviceInfo = useMemo(() => checkServiceAccess(), [checkServiceAccess]); + const hasAiAnalysis = Boolean(aiAnalysisResult); + const aiActionLabel = aiAnalysisLoading + ? t('medications.detail.aiAnalysis.analyzingButton') + : hasAiAnalysis + ? '重新分析' + : '获取 AI 分析'; const handleOpenNoteModal = useCallback(() => { setNoteDraft(medication?.note ?? ''); @@ -565,7 +592,12 @@ export default function MedicationDetailScreen() { } }, [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 dayStreakText = typeof summary.startedDays === 'number' @@ -802,6 +834,40 @@ export default function MedicationDetailScreen() { }); }, [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 ( + + + + + + {title} + + {items.length} + + + + {items.map((item, index) => ( + + + {item} + + ))} + + + ); + }, + [colors.text] + ); + const confirmDosagePicker = useCallback(async () => { if (!medication || updatePending) return; @@ -886,10 +952,9 @@ export default function MedicationDetailScreen() { // 2. 检查用户是否是 VIP 或有剩余免费次数 const serviceAccess = checkServiceAccess(); if (!serviceAccess.canUseService) { - // 如果不能使用服务,弹出会员购买弹窗 + setAiAnalysisLocked(true); openMembershipModal({ onPurchaseSuccess: () => { - // 购买成功后自动执行 AI 分析 handleAiAnalysis(); }, }); @@ -897,93 +962,45 @@ export default function MedicationDetailScreen() { } // 3. 通过验证,执行 AI 分析 - // 重置状态 - setAiAnalysisContent(''); setAiAnalysisLoading(true); + setAiAnalysisError(null); + setAiAnalysisLocked(false); // 滚动到底部,让用户看到分析内容 setTimeout(() => { scrollViewRef.current?.scrollToEnd({ animated: true }); }, 100); - // 创建 AbortController 用于取消 - const controller = new AbortController(); - setAiAnalysisAbortController(controller); - - try { - await analyzeMedicationStream( - medication.id, - { - onChunk: (chunk: string) => { - setAiAnalysisContent((prev) => prev + chunk); - }, - 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); - - let errorMessage = t('medications.detail.aiAnalysis.error.message'); - - // 解析服务端返回的错误信息 - if (error?.message) { - if (error.message.includes('[ERROR]')) { - errorMessage = error.message.replace('[ERROR]', '').trim(); - } 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); - }, - } - ); - } catch (error) { - console.error('[MEDICATION] AI 分析异常:', error); + try { + const result = await analyzeMedicationV2(medication.id); + setAiAnalysisResult(result); + // 本地保存一份,便于下次打开快速展示 + setMedication((prev) => (prev ? { ...prev, aiAnalysis: JSON.stringify(result) } : prev)); + } catch (error: any) { + console.error('[MEDICATION] AI 分析失败:', error); + const status = error?.status; + const message = + status === 403 + ? '免费使用次数已用完,请开通会员获取更多分析次数' + : status === 404 + ? '药品未找到' + : t('medications.detail.aiAnalysis.error.message'); - // 使用 Alert 弹窗显示错误 - Alert.alert(t('medications.detail.aiAnalysis.error.title'), t('medications.detail.aiAnalysis.error.networkError')); - - // 清空内容和加载状态 - setAiAnalysisContent(''); - setAiAnalysisLoading(false); - setAiAnalysisAbortController(null); - } - }, [medication, aiAnalysisLoading, ensureLoggedIn, checkServiceAccess, openMembershipModal]); - - // 组件卸载时取消 AI 分析请求 - useEffect(() => { - return () => { - if (aiAnalysisAbortController) { - aiAnalysisAbortController.abort(); + setAiAnalysisError(message); + if (status === 403) { + setAiAnalysisLocked(true); + openMembershipModal({ + onPurchaseSuccess: () => { + handleAiAnalysis(); + }, + }); + } else { + Alert.alert(t('medications.detail.aiAnalysis.error.title'), message); } - }; - }, [aiAnalysisAbortController]); + } finally { + setAiAnalysisLoading(false); + } + }, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, medication, openMembershipModal, t]); if (!medicationId) { return ( @@ -1205,6 +1222,176 @@ export default function MedicationDetailScreen() { +
+ + + + + 分析结果 + + + + + {hasAiAnalysis ? '已生成' : aiAnalysisLocked ? '会员专享' : '待生成'} + + + + + + + + + + {hasAiAnalysis && ( + + + AI 推荐 + + )} + + + + {medication.name} + + + {aiAnalysisResult?.mainUsage || '获取 AI 分析,快速了解适用人群、成分安全与使用建议。'} + + + + + {dosageLabel} + + + + {formLabel} + + + + + + {aiAnalysisLoading && ( + + + + {t('medications.detail.aiAnalysis.analyzing')} + + + )} + + {aiAnalysisResult ? ( + <> + {!!aiAnalysisResult.mainIngredients?.length && ( + + {aiAnalysisResult.mainIngredients.map((item) => ( + + + {item} + + + ))} + + )} + + + + {aiAnalysisResult.mainUsage} + + + + + + 适合人群 + + + {aiAnalysisResult.suitableFor.map((item, idx) => ( + + + {item} + + ))} + + + + + 不适合人群 + + + {aiAnalysisResult.unsuitableFor.map((item, idx) => ( + + + {item} + + ))} + + + + {renderAdviceCard('可能的副作用', aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')} + {renderAdviceCard('储存建议', aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')} + {renderAdviceCard('健康/使用建议', aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')} + + ) : null} + + {aiAnalysisError && ( + + + {aiAnalysisError} + + )} + + {(aiAnalysisLocked || !serviceInfo.canUseService) && ( + + openMembershipModal({ + onPurchaseSuccess: () => { + handleAiAnalysis(); + }, + }) + } + style={styles.aiMembershipCard} + > + + + + 会员专享 AI 深度解读 + 解锁完整药品分析与无限次使用 + + + + + )} + +
+
@@ -1221,103 +1408,7 @@ export default function MedicationDetailScreen() {
- {/* AI 分析结果展示 - 移动到底部 */} - {(aiAnalysisContent || aiAnalysisLoading) && ( -
- - {aiAnalysisLoading && !aiAnalysisContent && ( - - - - {t('medications.detail.aiAnalysis.analyzing')} - - - )} - - {aiAnalysisContent && ( - - - {aiAnalysisContent} - - {aiAnalysisLoading && ( - - - - )} - - )} - -
- )} + ) : null} @@ -1331,44 +1422,46 @@ export default function MedicationDetailScreen() { ]} > - {/* AI 分析按钮 - 占 2/3 宽度 */} - - {isLiquidGlassAvailable() ? ( - - {aiAnalysisLoading ? ( - - ) : ( - - )} - - {aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')} - - - ) : ( - - {aiAnalysisLoading ? ( - - ) : ( - - )} - - {aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')} - - - )} - + {/* AI 分析按钮 */} + {!hasAiAnalysis && ( + + {isLiquidGlassAvailable() ? ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiActionLabel} + + + ) : ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiActionLabel} + + + )} + + )} - {/* 删除按钮 - 占 1/3 宽度 */} + {/* 删除按钮 */} - + ) : ( - + )} @@ -2095,12 +2188,11 @@ const styles = StyleSheet.create({ borderTopColor: 'rgba(15,23,42,0.06)', }, deleteButton: { + width: 56, height: 56, - borderRadius: 24, - flexDirection: 'row', + borderRadius: 28, alignItems: 'center', justifyContent: 'center', - gap: 8, overflow: 'hidden', // 保证玻璃边界圆角效果 }, fallbackDeleteButton: { @@ -2115,12 +2207,13 @@ const styles = StyleSheet.create({ footerButtonContainer: { flexDirection: 'row', gap: 12, + justifyContent: 'flex-end', }, aiAnalysisButtonWrapper: { - flex: 2, // 占 2/3 宽度 + flex: 1, }, deleteButtonWrapper: { - flex: 1, // 占 1/3 宽度 + // auto width }, aiAnalysisButton: { height: 56, @@ -2145,10 +2238,10 @@ const styles = StyleSheet.create({ color: '#fff', }, // AI 分析卡片样式 - aiAnalysisCard: { - borderRadius: 24, + aiCardContainer: { + borderRadius: 26, padding: 20, - minHeight: 100, + gap: 14, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, @@ -2157,31 +2250,298 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(0, 0, 0, 0.04)', }, - aiAnalysisLoading: { + aiHeaderRow: { flexDirection: 'row', alignItems: 'center', - gap: 12, - paddingVertical: 12, + justifyContent: 'space-between', }, - aiAnalysisLoadingText: { - fontSize: 15, + aiHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, }, - aiAnalysisContentWrapper: { - gap: 12, - }, - aiAnalysisText: { - fontSize: 15, - lineHeight: 24, - }, - aiAnalysisStreaming: { - alignSelf: 'flex-start', - marginTop: 8, - }, - deleteButtonText: { + aiHeaderTitle: { fontSize: 17, 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', }, + 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 相关样式 pickerBackdrop: { flex: 1, diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 281a7e4..de4520f 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -20,6 +20,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, + Keyboard, KeyboardAvoidingView, Modal, Platform, @@ -31,6 +32,7 @@ import { TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; interface UserProfile { @@ -80,8 +82,9 @@ export default function EditProfileScreen() { const [pickerDate, setPickerDate] = useState(new Date()); const [editingField, setEditingField] = useState(null); const [tempValue, setTempValue] = useState(''); - - // 输入框字符串 + + // 键盘高度状态 + const [keyboardHeight, setKeyboardHeight] = useState(0); // 从本地存储加载(身高/体重等本地字段) const loadLocalProfile = async () => { @@ -128,6 +131,34 @@ export default function EditProfileScreen() { 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(() => { const loadMaximumHeartRate = async () => { @@ -439,6 +470,7 @@ export default function EditProfileScreen() { field={editingField} value={tempValue} profile={profile} + keyboardHeight={keyboardHeight} onClose={() => { setEditingField(null); 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; field: string | null; value: string; profile: UserProfile; + keyboardHeight: number; onClose: () => void; onSave: (field: string, value: string) => void; colors: any; @@ -569,6 +602,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te placeholderColor: string; t: (key: string) => string; }) { + const insets = useSafeAreaInsets(); const [inputValue, setInputValue] = useState(value); const [selectedGender, setSelectedGender] = useState(profile.gender || 'female'); const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1); @@ -685,7 +719,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te return ( - + {renderContent()} diff --git a/components/medication/MedicationCard.tsx b/components/medication/MedicationCard.tsx index 861605b..6b71f3a 100644 --- a/components/medication/MedicationCard.tsx +++ b/components/medication/MedicationCard.tsx @@ -1,7 +1,7 @@ import { ThemedText } from '@/components/ThemedText'; import { useAppDispatch } from '@/hooks/redux'; import { useI18n } from '@/hooks/useI18n'; -import { takeMedicationAction } from '@/store/medicationsSlice'; +import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice'; import type { MedicationDisplayItem } from '@/types/medication'; import { Ionicons } from '@expo/vector-icons'; 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 = () => { if (medication.status === 'missed') { return ( @@ -136,6 +194,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }; const renderAction = () => { + // 已服用状态 if (medication.status === 'taken') { return ( @@ -145,32 +204,73 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails ); } - // 只要没有服药,都可以显示立即服用 + // 已跳过状态 + if (medication.status === 'skipped') { + return ( + + + {t('medications.card.action.skipped')} + + ); + } + + // 待服用或已错过状态,显示操作按钮 return ( - - {isLiquidGlassAvailable() ? ( - - - {isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')} - - - ) : ( - - - {isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')} - - - )} - + + {/* 跳过按钮 */} + + {isLiquidGlassAvailable() ? ( + + + {t('medications.card.action.skip')} + + + ) : ( + + + {t('medications.card.action.skip')} + + + )} + + + {/* 立即服用按钮 */} + + {isLiquidGlassAvailable() ? ( + + + {isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')} + + + ) : ( + + + {isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')} + + + )} + + ); }; @@ -286,6 +386,16 @@ const styles = StyleSheet.create({ actionContainer: { marginTop: 8, }, + actionButtonsRow: { + flexDirection: 'row', + gap: 8, + }, + skipButtonWrapper: { + flex: 1, + }, + takeButtonWrapper: { + flex: 2, + }, actionButton: { alignSelf: 'stretch', flexDirection: 'row', @@ -302,6 +412,12 @@ const styles = StyleSheet.create({ actionButtonTaken: { backgroundColor: '#1FBF4B', }, + actionButtonSkipped: { + backgroundColor: '#9CA3AF', + }, + actionButtonSkip: { + backgroundColor: '#E5E7EB', + }, actionButtonMissed: { backgroundColor: '#9CA3AF', }, @@ -310,6 +426,11 @@ const styles = StyleSheet.create({ borderColor: 'rgba(19, 99, 255, 0.3)', 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: { borderWidth: 1, borderColor: 'rgba(156, 163, 175, 0.3)', @@ -320,6 +441,11 @@ const styles = StyleSheet.create({ fontWeight: '700', color: '#fff', }, + actionButtonTextSkip: { + fontSize: 14, + fontWeight: '600', + color: '#6B7280', + }, actionButtonTextMissed: { fontSize: 14, fontWeight: '700', diff --git a/i18n/index.ts b/i18n/index.ts index aa3b782..be0dda2 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -472,8 +472,16 @@ const medicationsResources = { action: { takeNow: '立即服用', taken: '已服用', + skipped: '已跳过', + skip: '跳过', submitting: '提交中...', }, + skipAlert: { + title: '确认跳过', + message: '确定要跳过本次用药吗?\n\n跳过后将不会记录为已服用。', + cancel: '取消', + confirm: '确认跳过', + }, earlyTakeAlert: { title: '尚未到服药时间', message: '该用药计划在 {{time}},现在还早于1小时以上。\n\n是否确认已服用此药物?', @@ -485,6 +493,11 @@ const medicationsResources = { message: '记录服药时发生错误,请稍后重试', confirm: '确定', }, + skipError: { + title: '操作失败', + message: '跳过操作失败,请稍后重试', + confirm: '确定', + }, }, // 添加药物页面翻译 add: { @@ -1225,8 +1238,16 @@ const resources = { action: { takeNow: 'Take Now', taken: 'Taken', + skipped: 'Skipped', + skip: 'Skip', 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: { 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?', @@ -1238,6 +1259,11 @@ const resources = { message: 'An error occurred while recording medication, please try again later', confirm: 'OK', }, + skipError: { + title: 'Operation Failed', + message: 'Skip operation failed, please try again later', + confirm: 'OK', + }, }, // 添加药物页面翻译 add: { diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 7560b28..589df1b 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.27 + 1.0.28 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/api.ts b/services/api.ts index 44bbd02..ec171bc 100644 --- a/services/api.ts +++ b/services/api.ts @@ -144,7 +144,7 @@ async function doFetch(path: string, options: ApiRequestOptions = {}): Promis 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 error = new Error(errorMessage); // @ts-expect-error augment @@ -324,4 +324,3 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr return { abort, requestId }; } - diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index c05f90c..3e10e9d 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -1,12 +1,12 @@ import { listChallenges } from '@/services/challengesApi'; +import { resyncFastingNotifications } from '@/services/fastingNotifications'; import { store } from '@/store'; +import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice'; import { getWaterIntakeFromHealthKit } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; import { log } from '@/utils/logger'; import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; -import { getWaterGoalFromStorage } from '@/utils/userPreferences'; -import { resyncFastingNotifications } from '@/services/fastingNotifications'; -import { selectActiveFastingSchedule, selectActiveFastingPlan } from '@/store/fastingSlice'; +import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences'; import dayjs from 'dayjs'; import * as BackgroundTask from 'expo-background-task'; import * as TaskManager from 'expo-task-manager'; @@ -33,6 +33,13 @@ async function executeWaterReminderTask(): Promise { try { console.log('执行喝水提醒后台任务...'); + // 检查是否开启了喝水提醒 + const isEnabled = await getWaterReminderEnabled(); + if (!isEnabled) { + console.log('喝水提醒未开启,跳过后台任务'); + return; + } + // 获取当前状态,添加错误处理 let state; try { diff --git a/services/backgroundTaskManagerV2.ts b/services/backgroundTaskManagerV2.ts index 485b91b..c8d0fdd 100644 --- a/services/backgroundTaskManagerV2.ts +++ b/services/backgroundTaskManagerV2.ts @@ -8,7 +8,7 @@ import { getWaterIntakeFromHealthKit } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; import { logger } from '@/utils/logger'; import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; -import { getWaterGoalFromStorage } from '@/utils/userPreferences'; +import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences'; import dayjs from 'dayjs'; @@ -39,6 +39,13 @@ async function executeWaterReminderTask(): Promise { try { console.log('执行喝水提醒后台任务...'); + // 检查是否开启了喝水提醒 + const isEnabled = await getWaterReminderEnabled(); + if (!isEnabled) { + console.log('喝水提醒未开启,跳过后台任务'); + return; + } + let state; try { state = store.getState(); diff --git a/services/medications.ts b/services/medications.ts index 8a664ff..1807204 100644 --- a/services/medications.ts +++ b/services/medications.ts @@ -5,6 +5,7 @@ import type { DailyMedicationStats, Medication, + MedicationAiAnalysisV2, MedicationForm, MedicationRecord, MedicationStatus, @@ -328,4 +329,18 @@ export async function analyzeMedicationStream( callbacks, { timeoutMs: 120000 } ); -} \ No newline at end of file +} + +/** + * 获取药品 AI 分析 V2 结构化报告 + * @param medicationId 药品 ID + * @returns 结构化 AI 分析结果 + */ +export async function analyzeMedicationV2( + medicationId: string +): Promise { + return api.post( + `/api/medications/${medicationId}/ai-analysis/v2`, + {} + ); +} diff --git a/types/medication.ts b/types/medication.ts index 76167d6..983ebf9 100644 --- a/types/medication.ts +++ b/types/medication.ts @@ -91,4 +91,17 @@ export interface MedicationDisplayItem { image?: any; // 图片资源 recordId?: string; // 服药记录ID(用于更新状态) medicationId: string; // 药物ID -} \ No newline at end of file +} + +/** + * 药品 AI 分析 V2 结构化数据 + */ +export interface MedicationAiAnalysisV2 { + suitableFor: string[]; // 适合人群 + unsuitableFor: string[]; // 不适合人群/慎用 + mainIngredients: string[]; // 主要成分 + mainUsage: string; // 主要用途/功效 + sideEffects: string[]; // 常见副作用 + storageAdvice: string[]; // 储存建议 + healthAdvice: string[]; // 健康建议/使用建议 +} diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 5b1ecc1..df1e773 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -1,6 +1,6 @@ import * as Notifications from 'expo-notifications'; import { NotificationData, NotificationTypes, notificationService } from '../services/notifications'; -import { getNotificationEnabled } from './userPreferences'; +import { getNotificationEnabled, getWaterReminderEnabled } from './userPreferences'; /** * 构建 coach 页面的深度链接 @@ -433,6 +433,13 @@ export class WaterNotificationHelpers { currentHour: number = new Date().getHours() ): Promise { try { + // 首先检查用户是否启用了喝水提醒 + const isWaterReminderEnabled = await getWaterReminderEnabled(); + if (!isWaterReminderEnabled) { + console.log('用户未启用喝水提醒,跳过通知检查'); + return false; + } + // 检查时间限制:早上9点以前和晚上9点以后不通知 if (currentHour < 9 || currentHour >= 23) { console.log(`当前时间${currentHour}点,不在通知时间范围内(9:00-21:00),跳过喝水提醒`); @@ -546,6 +553,15 @@ export class WaterNotificationHelpers { */ static async scheduleRegularWaterReminders(userName: string): Promise { try { + // 首先检查用户是否启用了喝水提醒 + const isWaterReminderEnabled = await getWaterReminderEnabled(); + if (!isWaterReminderEnabled) { + console.log('用户未启用喝水提醒,不安排定期提醒'); + // 确保取消任何可能存在的旧提醒 + await this.cancelAllWaterReminders(); + return []; + } + const notificationIds: string[] = []; // 检查是否已经存在定期喝水提醒