feat(medications): 添加AI用药分析功能

- 集成流式AI分析接口,支持实时展示分析结果
- 添加VIP权限校验和会员弹窗引导
- 使用Markdown渲染AI分析内容
- 优化底部按钮布局,AI分析按钮占2/3宽度
- 支持请求取消和错误处理
- 自动滚动到分析结果区域
- InfoCard组件优化,图标、标签和箭头排列在同一行
This commit is contained in:
richarjiang
2025-11-12 17:07:42 +08:00
parent 0bea454dca
commit 7c8538f5c6
4 changed files with 405 additions and 45 deletions

View File

@@ -5,10 +5,17 @@ import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication'; import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useVipService } from '@/hooks/useVipService';
import { medicationNotificationService } from '@/services/medicationNotifications'; import { medicationNotificationService } from '@/services/medicationNotifications';
import { getMedicationById, getMedicationRecords } from '@/services/medications'; import {
analyzeMedicationStream,
getMedicationById,
getMedicationRecords,
} from '@/services/medications';
import { import {
deleteMedicationAction, deleteMedicationAction,
fetchMedications, fetchMedications,
@@ -41,6 +48,7 @@ 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');
@@ -60,6 +68,9 @@ export default function MedicationDetailScreen() {
const colors = Colors[scheme]; const colors = Colors[scheme];
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const { checkServiceAccess } = useVipService();
const medications = useAppSelector(selectMedications); const medications = useAppSelector(selectMedications);
const medicationFromStore = medications.find((item) => item.id === medicationId); const medicationFromStore = medications.find((item) => item.id === medicationId);
@@ -86,6 +97,11 @@ export default function MedicationDetailScreen() {
const [deactivateLoading, setDeactivateLoading] = useState(false); const [deactivateLoading, setDeactivateLoading] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false); const [showImagePreview, setShowImagePreview] = useState(false);
// AI 分析相关状态
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
const [aiAnalysisContent, setAiAnalysisContent] = useState('');
const [aiAnalysisAbortController, setAiAnalysisAbortController] = useState<AbortController | null>(null);
// 剂量选择相关状态 // 剂量选择相关状态
const [dosagePickerVisible, setDosagePickerVisible] = useState(false); const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
const [dosageValuePicker, setDosageValuePicker] = useState( const [dosageValuePicker, setDosageValuePicker] = useState(
@@ -101,6 +117,9 @@ export default function MedicationDetailScreen() {
medicationFromStore?.form ?? 'capsule' medicationFromStore?.form ?? 'capsule'
); );
// ScrollView 引用,用于滚动到底部
const scrollViewRef = React.useRef<ScrollView>(null);
useEffect(() => { useEffect(() => {
if (!medicationFromStore) { if (!medicationFromStore) {
dispatch(fetchMedications()); dispatch(fetchMedications());
@@ -111,6 +130,10 @@ export default function MedicationDetailScreen() {
if (medicationFromStore) { if (medicationFromStore) {
setMedication(medicationFromStore); setMedication(medicationFromStore);
setLoading(false); setLoading(false);
// 如果服务端返回了 AI 分析结果,自动展示
if (medicationFromStore.aiAnalysis) {
setAiAnalysisContent(medicationFromStore.aiAnalysis);
}
} }
}, [medicationFromStore]); }, [medicationFromStore]);
@@ -165,6 +188,10 @@ export default function MedicationDetailScreen() {
console.log('[MEDICATION_DETAIL] API call successful', data); console.log('[MEDICATION_DETAIL] API call successful', data);
setMedication(data); setMedication(data);
setError(null); setError(null);
// 如果服务端返回了 AI 分析结果,自动展示
if (data.aiAnalysis) {
setAiAnalysisContent(data.aiAnalysis);
}
}) })
.catch((err) => { .catch((err) => {
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -635,6 +662,118 @@ export default function MedicationDetailScreen() {
} }
}, [dispatch, formPicker, medication, updatePending]); }, [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) { if (!medicationId) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -691,6 +830,7 @@ export default function MedicationDetailScreen() {
</View> </View>
) : medication ? ( ) : medication ? (
<ScrollView <ScrollView
ref={scrollViewRef}
contentContainerStyle={[ contentContainerStyle={[
styles.content, styles.content,
{ {
@@ -831,6 +971,104 @@ export default function MedicationDetailScreen() {
</View> </View>
</View> </View>
</Section> </Section>
{/* AI 分析结果展示 - 移动到底部 */}
{(aiAnalysisContent || aiAnalysisLoading) && (
<Section title="AI 用药分析" 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 }]}>
...
</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}
@@ -843,27 +1081,66 @@ export default function MedicationDetailScreen() {
}, },
]} ]}
> >
<TouchableOpacity <View style={styles.footerButtonContainer}>
activeOpacity={0.9} {/* AI 分析按钮 - 占 2/3 宽度 */}
onPress={() => setDeleteSheetVisible(true)} <TouchableOpacity
> style={styles.aiAnalysisButtonWrapper}
{isLiquidGlassAvailable() ? ( activeOpacity={0.9}
<GlassView onPress={handleAiAnalysis}
style={styles.deleteButton} disabled={aiAnalysisLoading}
glassEffectStyle="regular" >
tintColor="rgba(239, 68, 68, 0.8)" {isLiquidGlassAvailable() ? (
isInteractive={true} <GlassView
> style={styles.aiAnalysisButton}
<Ionicons name='trash-outline' size={18} color='#fff' /> glassEffectStyle="regular"
<Text style={styles.deleteButtonText}></Text> tintColor="rgba(59, 130, 246, 0.8)"
</GlassView> isInteractive={true}
) : ( >
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}> {aiAnalysisLoading ? (
<Ionicons name='trash-outline' size={18} color='#fff' /> <ActivityIndicator size="small" color="#fff" />
<Text style={styles.deleteButtonText}></Text> ) : (
</View> <Ionicons name="sparkles-outline" size={18} color="#fff" />
)} )}
</TouchableOpacity> <Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
</Text>
</GlassView>
) : (
<View style={[styles.aiAnalysisButton, styles.fallbackAiButton]}>
{aiAnalysisLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
</Text>
</View>
)}
</TouchableOpacity>
{/* 删除按钮 - 占 1/3 宽度 */}
<TouchableOpacity
style={styles.deleteButtonWrapper}
activeOpacity={0.9}
onPress={() => setDeleteSheetVisible(true)}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.deleteButton}
glassEffectStyle="regular"
tintColor="rgba(239, 68, 68, 0.8)"
isInteractive={true}
>
<Ionicons name="trash-outline" size={18} color="#fff" />
</GlassView>
) : (
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
<Ionicons name="trash-outline" size={18} color="#fff" />
</View>
)}
</TouchableOpacity>
</View>
</View> </View>
) : null} ) : null}
@@ -1470,6 +1747,72 @@ const styles = StyleSheet.create({
shadowRadius: 20, shadowRadius: 20,
elevation: 6, 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: { deleteButtonText: {
fontSize: 17, fontSize: 17,
fontWeight: '700', fontWeight: '700',

View File

@@ -31,26 +31,21 @@ export const InfoCard: React.FC<InfoCardProps> = ({
); );
}; };
// 渲染箭头 - 只在可点击时显示 // 卡片内容 - icon、label 和箭头在同一行
const renderArrow = () => {
if (!clickable) return null;
return (
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
);
};
// 卡片内容
const cardContent = ( const cardContent = (
<View style={[ <View style={[
styles.infoCard, styles.infoCard,
{ backgroundColor: colors.surface || '#fff' } { backgroundColor: colors.surface || '#fff' }
]}> ]}>
{renderArrow()} <View style={styles.header}>
{renderIcon()} {renderIcon()}
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text> <Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
{clickable && (
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
)}
</View>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text> <Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</View> </View>
); );
@@ -85,14 +80,11 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
padding: 16, padding: 16,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
gap: 6, gap: 8,
position: 'relative', position: 'relative',
}, },
infoCardArrow: { infoCardArrow: {
position: 'absolute', marginLeft: 'auto',
top: 12,
right: 12,
zIndex: 1,
width: 24, width: 24,
height: 24, height: 24,
borderRadius: 12, borderRadius: 12,
@@ -106,10 +98,14 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
infoCardLabel: { infoCardLabel: {
fontSize: 13, fontSize: 13,
color: '#6B7280', color: '#6B7280',
marginTop: 8,
}, },
infoCardValue: { infoCardValue: {
fontSize: 14, fontSize: 14,

View File

@@ -10,7 +10,7 @@ import type {
MedicationStatus, MedicationStatus,
RepeatPattern, RepeatPattern,
} from '@/types/medication'; } from '@/types/medication';
import { api } from './api'; import { api, postTextStream, type TextStreamCallbacks } from './api';
// ==================== DTO 类型定义 ==================== // ==================== DTO 类型定义 ====================
@@ -309,3 +309,23 @@ export const getOverallStats = async (): Promise<{
}> => { }> => {
return api.get(`/medication-stats/overall`); return api.get(`/medication-stats/overall`);
}; };
// ==================== 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 }
);
}

View File

@@ -42,6 +42,7 @@ export interface Medication {
endDate?: string | null; // 结束日期 ISO可选 endDate?: string | null; // 结束日期 ISO可选
repeatPattern: RepeatPattern; // 重复模式 repeatPattern: RepeatPattern; // 重复模式
note?: string; // 备注 note?: string; // 备注
aiAnalysis?: string; // AI 分析结果Markdown 格式)
isActive: boolean; // 是否激活 isActive: boolean; // 是否激活
deleted: boolean; // 是否已删除(软删除标记) deleted: boolean; // 是否已删除(软删除标记)
createdAt: string; // 创建时间 createdAt: string; // 创建时间