feat(medication): 重构AI分析为结构化展示并支持喝水提醒个性化配置
- 将药品AI分析从Markdown流式输出重构为结构化数据展示(V2) - 新增适合人群、不适合人群、主要成分、副作用等分类卡片展示 - 优化AI分析UI布局,采用卡片式设计提升可读性 - 新增药品跳过功能,支持用户标记本次用药为已跳过 - 修复喝水提醒逻辑,支持用户开关控制和自定义时间段配置 - 优化个人资料编辑页面键盘适配,避免输入框被遮挡 - 统一API响应码处理,兼容200和0两种成功状态码 - 更新版本号至1.0.28 BREAKING CHANGE: 药品AI分析接口从流式Markdown输出改为结构化JSON格式,旧版本分析结果将不再显示
This commit is contained in:
@@ -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.ts,scheduleRegularWaterReminders 是每2小时一次的固定逻辑
|
||||||
|
// 而 scheduleCustomWaterReminders 是根据用户设置的时间段和间隔
|
||||||
|
|
||||||
|
// 如果用户开启了提醒,应该使用 scheduleCustomWaterReminders
|
||||||
|
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', settings).then(() =>
|
||||||
|
logger.info('✅ 自定义喝水提醒已注册')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info('ℹ️ 用户未开启喝水提醒,跳过注册');
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 检查断食通知(如果有活跃计划)
|
// 检查断食通知(如果有活跃计划)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[]; // 健康建议/使用建议
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
// 检查是否已经存在定期喝水提醒
|
// 检查是否已经存在定期喝水提醒
|
||||||
|
|||||||
Reference in New Issue
Block a user