From 7c8538f5c63ca14424c3ef33fa91529297dfa391 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 12 Nov 2025 17:07:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E6=B7=BB=E5=8A=A0AI?= =?UTF-8?q?=E7=94=A8=E8=8D=AF=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成流式AI分析接口,支持实时展示分析结果 - 添加VIP权限校验和会员弹窗引导 - 使用Markdown渲染AI分析内容 - 优化底部按钮布局,AI分析按钮占2/3宽度 - 支持请求取消和错误处理 - 自动滚动到分析结果区域 - InfoCard组件优化,图标、标签和箭头排列在同一行 --- app/medications/[medicationId].tsx | 387 +++++++++++++++++++++++++++-- components/ui/InfoCard.tsx | 38 ++- services/medications.ts | 24 +- types/medication.ts | 1 + 4 files changed, 405 insertions(+), 45 deletions(-) diff --git a/app/medications/[medicationId].tsx b/app/medications/[medicationId].tsx index 07d3158..f550ff9 100644 --- a/app/medications/[medicationId].tsx +++ b/app/medications/[medicationId].tsx @@ -5,10 +5,17 @@ import InfoCard from '@/components/ui/InfoCard'; import { Colors } from '@/constants/Colors'; import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication'; import { ROUTES } from '@/constants/Routes'; +import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useVipService } from '@/hooks/useVipService'; import { medicationNotificationService } from '@/services/medicationNotifications'; -import { getMedicationById, getMedicationRecords } from '@/services/medications'; +import { + analyzeMedicationStream, + getMedicationById, + getMedicationRecords, +} from '@/services/medications'; import { deleteMedicationAction, fetchMedications, @@ -41,6 +48,7 @@ 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'); @@ -60,6 +68,9 @@ export default function MedicationDetailScreen() { const colors = Colors[scheme]; const insets = useSafeAreaInsets(); const router = useRouter(); + const { ensureLoggedIn } = useAuthGuard(); + const { openMembershipModal } = useMembershipModal(); + const { checkServiceAccess } = useVipService(); const medications = useAppSelector(selectMedications); const medicationFromStore = medications.find((item) => item.id === medicationId); @@ -86,6 +97,11 @@ export default function MedicationDetailScreen() { const [deactivateLoading, setDeactivateLoading] = useState(false); const [showImagePreview, setShowImagePreview] = useState(false); + // AI 分析相关状态 + const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false); + const [aiAnalysisContent, setAiAnalysisContent] = useState(''); + const [aiAnalysisAbortController, setAiAnalysisAbortController] = useState(null); + // 剂量选择相关状态 const [dosagePickerVisible, setDosagePickerVisible] = useState(false); const [dosageValuePicker, setDosageValuePicker] = useState( @@ -100,6 +116,9 @@ export default function MedicationDetailScreen() { const [formPicker, setFormPicker] = useState( medicationFromStore?.form ?? 'capsule' ); + + // ScrollView 引用,用于滚动到底部 + const scrollViewRef = React.useRef(null); useEffect(() => { if (!medicationFromStore) { @@ -111,6 +130,10 @@ export default function MedicationDetailScreen() { if (medicationFromStore) { setMedication(medicationFromStore); setLoading(false); + // 如果服务端返回了 AI 分析结果,自动展示 + if (medicationFromStore.aiAnalysis) { + setAiAnalysisContent(medicationFromStore.aiAnalysis); + } } }, [medicationFromStore]); @@ -165,6 +188,10 @@ export default function MedicationDetailScreen() { console.log('[MEDICATION_DETAIL] API call successful', data); setMedication(data); setError(null); + // 如果服务端返回了 AI 分析结果,自动展示 + if (data.aiAnalysis) { + setAiAnalysisContent(data.aiAnalysis); + } }) .catch((err) => { if (abortController.signal.aborted) return; @@ -635,6 +662,118 @@ export default function MedicationDetailScreen() { } }, [dispatch, formPicker, medication, updatePending]); + // AI 分析处理函数 + const handleAiAnalysis = useCallback(async () => { + if (!medication || aiAnalysisLoading) return; + + // 1. 先验证用户是否登录 + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + return; // 如果未登录,ensureLoggedIn 会自动跳转到登录页 + } + + // 2. 检查用户是否是 VIP 或有剩余免费次数 + const serviceAccess = checkServiceAccess(); + if (!serviceAccess.canUseService) { + // 如果不能使用服务,弹出会员购买弹窗 + openMembershipModal({ + onPurchaseSuccess: () => { + // 购买成功后自动执行 AI 分析 + handleAiAnalysis(); + }, + }); + return; + } + + // 3. 通过验证,执行 AI 分析 + // 重置状态 + setAiAnalysisContent(''); + setAiAnalysisLoading(true); + + // 滚动到底部,让用户看到分析内容 + 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 = 'AI 分析失败,请稍后重试'; + + // 解析服务端返回的错误信息 + if (error?.message) { + if (error.message.includes('[ERROR]')) { + errorMessage = error.message.replace('[ERROR]', '').trim(); + } else if (error.message.includes('无权访问')) { + errorMessage = '无权访问此药物'; + } else if (error.message.includes('不存在')) { + errorMessage = '药物不存在'; + } + } else if (error?.status === 401) { + errorMessage = '请先登录'; + } else if (error?.status === 403) { + errorMessage = '无权访问此药物'; + } else if (error?.status === 404) { + errorMessage = '药物不存在'; + } + + // 使用 Alert 弹窗显示错误 + Alert.alert('分析失败', errorMessage); + + // 清空内容和加载状态 + setAiAnalysisContent(''); + setAiAnalysisLoading(false); + setAiAnalysisAbortController(null); + }, + } + ); + } catch (error) { + console.error('[MEDICATION] AI 分析异常:', error); + + // 使用 Alert 弹窗显示错误 + Alert.alert('分析失败', '发起分析请求失败,请检查网络连接'); + + // 清空内容和加载状态 + setAiAnalysisContent(''); + setAiAnalysisLoading(false); + setAiAnalysisAbortController(null); + } + }, [medication, aiAnalysisLoading, ensureLoggedIn, checkServiceAccess, openMembershipModal]); + + // 组件卸载时取消 AI 分析请求 + useEffect(() => { + return () => { + if (aiAnalysisAbortController) { + aiAnalysisAbortController.abort(); + } + }; + }, [aiAnalysisAbortController]); + if (!medicationId) { return ( @@ -691,6 +830,7 @@ export default function MedicationDetailScreen() { ) : medication ? ( + + {/* AI 分析结果展示 - 移动到底部 */} + {(aiAnalysisContent || aiAnalysisLoading) && ( +
+ + {aiAnalysisLoading && !aiAnalysisContent && ( + + + + 正在分析用药信息... + + + )} + + {aiAnalysisContent && ( + + + {aiAnalysisContent} + + {aiAnalysisLoading && ( + + + + )} + + )} + +
+ )}
) : null} @@ -843,27 +1081,66 @@ export default function MedicationDetailScreen() { }, ]} > - setDeleteSheetVisible(true)} - > - {isLiquidGlassAvailable() ? ( - - - 删除该药品 - - ) : ( - - - 删除该药品 - - )} - + + {/* AI 分析按钮 - 占 2/3 宽度 */} + + {isLiquidGlassAvailable() ? ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiAnalysisLoading ? '分析中...' : 'AI 分析'} + + + ) : ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiAnalysisLoading ? '分析中...' : 'AI 分析'} + + + )} + + + {/* 删除按钮 - 占 1/3 宽度 */} + setDeleteSheetVisible(true)} + > + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + ) : null} @@ -1470,6 +1747,72 @@ const styles = StyleSheet.create({ shadowRadius: 20, elevation: 6, }, + // 底部按钮容器样式 + footerButtonContainer: { + flexDirection: 'row', + gap: 12, + }, + aiAnalysisButtonWrapper: { + flex: 2, // 占 2/3 宽度 + }, + deleteButtonWrapper: { + flex: 1, // 占 1/3 宽度 + }, + aiAnalysisButton: { + height: 56, + borderRadius: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + overflow: 'hidden', + }, + fallbackAiButton: { + backgroundColor: '#3B82F6', + shadowColor: 'rgba(59,130,246,0.4)', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 1, + shadowRadius: 20, + elevation: 6, + }, + aiAnalysisButtonText: { + fontSize: 17, + fontWeight: '700', + color: '#fff', + }, + // AI 分析卡片样式 + aiAnalysisCard: { + borderRadius: 24, + padding: 20, + minHeight: 100, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 3 }, + elevation: 3, + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.04)', + }, + aiAnalysisLoading: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 12, + }, + aiAnalysisLoadingText: { + fontSize: 15, + }, + aiAnalysisContentWrapper: { + gap: 12, + }, + aiAnalysisText: { + fontSize: 15, + lineHeight: 24, + }, + aiAnalysisStreaming: { + alignSelf: 'flex-start', + marginTop: 8, + }, deleteButtonText: { fontSize: 17, fontWeight: '700', diff --git a/components/ui/InfoCard.tsx b/components/ui/InfoCard.tsx index 516f9c5..0013ed4 100644 --- a/components/ui/InfoCard.tsx +++ b/components/ui/InfoCard.tsx @@ -31,26 +31,21 @@ export const InfoCard: React.FC = ({ ); }; - // 渲染箭头 - 只在可点击时显示 - const renderArrow = () => { - if (!clickable) return null; - - return ( - - - - ); - }; - - // 卡片内容 + // 卡片内容 - icon、label 和箭头在同一行 const cardContent = ( - {renderArrow()} - {renderIcon()} - {label} + + {renderIcon()} + {label} + {clickable && ( + + + + )} + {value} ); @@ -85,14 +80,11 @@ const styles = StyleSheet.create({ borderRadius: 20, padding: 16, backgroundColor: '#FFFFFF', - gap: 6, + gap: 8, position: 'relative', }, infoCardArrow: { - position: 'absolute', - top: 12, - right: 12, - zIndex: 1, + marginLeft: 'auto', width: 24, height: 24, borderRadius: 12, @@ -106,10 +98,14 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, infoCardLabel: { fontSize: 13, color: '#6B7280', - marginTop: 8, }, infoCardValue: { fontSize: 14, diff --git a/services/medications.ts b/services/medications.ts index 86e5c4b..8a664ff 100644 --- a/services/medications.ts +++ b/services/medications.ts @@ -10,7 +10,7 @@ import type { MedicationStatus, RepeatPattern, } from '@/types/medication'; -import { api } from './api'; +import { api, postTextStream, type TextStreamCallbacks } from './api'; // ==================== DTO 类型定义 ==================== @@ -308,4 +308,24 @@ export const getOverallStats = async (): Promise<{ streak: number; }> => { return api.get(`/medication-stats/overall`); -}; \ No newline at end of file +}; + +// ==================== AI 分析相关 ==================== + +/** + * 流式获取药品 AI 分析 + * @param medicationId 药品 ID + * @param callbacks 流式回调 + * @returns 包含 abort 方法的对象,用于取消请求 + */ +export async function analyzeMedicationStream( + medicationId: string, + callbacks: TextStreamCallbacks +) { + return postTextStream( + `/api/medications/${medicationId}/ai-analysis`, + {}, + callbacks, + { timeoutMs: 120000 } + ); +} \ No newline at end of file diff --git a/types/medication.ts b/types/medication.ts index fcea54d..76167d6 100644 --- a/types/medication.ts +++ b/types/medication.ts @@ -42,6 +42,7 @@ export interface Medication { endDate?: string | null; // 结束日期 ISO(可选) repeatPattern: RepeatPattern; // 重复模式 note?: string; // 备注 + aiAnalysis?: string; // AI 分析结果(Markdown 格式) isActive: boolean; // 是否激活 deleted: boolean; // 是否已删除(软删除标记) createdAt: string; // 创建时间