feat(medications): 添加AI用药分析功能
- 集成流式AI分析接口,支持实时展示分析结果 - 添加VIP权限校验和会员弹窗引导 - 使用Markdown渲染AI分析内容 - 优化底部按钮布局,AI分析按钮占2/3宽度 - 支持请求取消和错误处理 - 自动滚动到分析结果区域 - InfoCard组件优化,图标、标签和箭头排列在同一行
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; // 创建时间
|
||||||
|
|||||||
Reference in New Issue
Block a user