feat(medications): 添加AI智能识别药品功能和有效期管理

- 新增AI药品识别流程,支持多角度拍摄和实时进度显示
- 添加药品有效期字段,支持在添加和编辑药品时设置有效期
- 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入)
- 新增ai-camera和ai-progress两个独立页面处理AI识别流程
- 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件
- 移除本地通知系统,迁移到服务端推送通知
- 添加medicationNotificationCleanup服务清理旧的本地通知
- 更新药品详情页支持AI草稿模式和有效期显示
- 优化药品表单,支持有效期选择和AI识别结果确认
- 更新i18n资源,添加有效期相关翻译

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
richarjiang
2025-11-21 17:32:44 +08:00
parent 29942feee9
commit bcb910140e
18 changed files with 2735 additions and 407 deletions

View File

@@ -0,0 +1,205 @@
import { ThemedText } from '@/components/ThemedText';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Modal, Platform, Pressable, StyleSheet, View } from 'react-native';
interface ExpiryDatePickerModalProps {
visible: boolean;
currentDate: Date | null;
onClose: () => void;
onConfirm: (date: Date) => void;
isAiDraft?: boolean;
}
/**
* 有效期日期选择器组件
*
* 功能:
* - 显示日期选择器弹窗
* - 验证日期不能早于今天
* - iOS 显示内联日历Android 显示原生对话框
* - 支持取消和确认操作
*/
export function ExpiryDatePickerModal({
visible,
currentDate,
onClose,
onConfirm,
isAiDraft = false,
}: ExpiryDatePickerModalProps) {
const { t } = useI18n();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
// 内部状态:选择的日期值
const [selectedDate, setSelectedDate] = useState<Date>(currentDate || new Date());
// 当弹窗显示时,同步当前日期
useEffect(() => {
if (visible) {
setSelectedDate(currentDate || new Date());
}
}, [visible, currentDate]);
/**
* 处理日期变化
* iOS: 实时更新选择的日期
* Android: 在用户点击确定时直接确认
*/
const handleDateChange = useCallback(
(event: any, date?: Date) => {
if (Platform.OS === 'ios') {
// iOS: 实时更新内部状态
if (date) {
setSelectedDate(date);
}
} else {
// Android: 处理用户操作
if (event.type === 'set' && date) {
// 用户点击确定
validateAndConfirm(date);
} else {
// 用户点击取消
onClose();
}
}
},
[onClose]
);
/**
* 验证并确认日期
*/
const validateAndConfirm = useCallback(
(dateToConfirm: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(dateToConfirm);
selected.setHours(0, 0, 0, 0);
if (selected < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
// 检查日期是否真的发生了变化
const currentExpiry = currentDate ? dayjs(currentDate).format('YYYY-MM-DD') : null;
const newExpiry = dayjs(dateToConfirm).format('YYYY-MM-DD');
if (currentExpiry === newExpiry) {
// 日期没有变化,直接关闭
onClose();
return;
}
// 日期有效且发生了变化,执行确认回调
onConfirm(dateToConfirm);
onClose();
},
[currentDate, onClose, onConfirm]
);
/**
* iOS 平台的确认按钮处理
*/
const handleIOSConfirm = useCallback(() => {
validateAndConfirm(selectedDate);
}, [selectedDate, validateAndConfirm]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.title, { color: colors.text }]}>
</ThemedText>
<DateTimePicker
value={selectedDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date()}
onChange={handleDateChange}
locale="zh-CN"
/>
{/* iOS 平台显示确认和取消按钮 */}
{Platform.OS === 'ios' && (
<View style={styles.actions}>
<Pressable
onPress={onClose}
style={[styles.btn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.btnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
onPress={handleIOSConfirm}
style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.btnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
sheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 20,
textAlign: 'center',
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
btn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
btnPrimary: {
borderWidth: 0,
},
btnText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,265 @@
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import {
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface MedicationPhotoGuideModalProps {
visible: boolean;
onClose: () => void;
}
/**
* 药品拍摄指南弹窗组件
* 展示如何正确拍摄药品照片的说明和示例
*/
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.guideModalContainer}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.guideModalContent}
>
{/* 标题部分 */}
<View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text>
<Text style={styles.guideTitle}></Text>
</View>
{/* 示例图片 */}
<View style={styles.guideImagesContainer}>
{/* 正确示例 */}
<View style={styles.guideImageWrapper}>
<View style={styles.guideImageBox}>
<Ionicons
name="checkmark-circle"
size={32}
color="#4CAF50"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={styles.guideImage}
contentFit="cover"
/>
</View>
<View style={styles.guideImageIndicator}>
<Ionicons name="checkmark-circle" size={20} color="#4CAF50" />
</View>
</View>
{/* 错误示例 */}
<View style={styles.guideImageWrapper}>
<View style={[styles.guideImageBox, styles.guideImageBoxBlur]}>
<Ionicons
name="close-circle"
size={32}
color="#F44336"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={[styles.guideImage, { opacity: 0.5 }]}
contentFit="cover"
blurRadius={8}
/>
</View>
<View style={[styles.guideImageIndicator, styles.guideImageIndicatorError]}>
<Ionicons name="close-circle" size={20} color="#F44336" />
</View>
</View>
</View>
{/* 说明文字 */}
<View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}>
\\
</Text>
<Text style={styles.guideDescriptionText}>
线
</Text>
</View>
{/* 确认按钮 */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.guideConfirmButton}
glassEffectStyle="regular"
tintColor="rgba(255, 179, 0, 0.9)"
isInteractive={true}
>
<LinearGradient
colors={['rgba(255, 179, 0, 0.95)', 'rgba(255, 160, 0, 0.95)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
</LinearGradient>
</GlassView>
) : (
<View style={styles.guideConfirmButton}>
<LinearGradient
colors={['#FFB300', '#FFA000']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
</LinearGradient>
</View>
)}
</TouchableOpacity>
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
guideModalContainer: {
width: SCREEN_WIDTH - 48,
maxHeight: '80%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 20,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
guideModalContent: {
padding: 24,
},
guideHeader: {
alignItems: 'center',
marginBottom: 24,
},
guideStepBadge: {
fontSize: 16,
fontWeight: '700',
color: '#FFB300',
marginBottom: 8,
},
guideTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
textAlign: 'center',
},
guideImagesContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
gap: 12,
},
guideImageWrapper: {
flex: 1,
alignItems: 'center',
},
guideImageBox: {
width: '100%',
aspectRatio: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#f8fafc',
position: 'relative',
borderWidth: 2,
borderColor: '#4CAF50',
},
guideImageBoxBlur: {
borderColor: '#F44336',
},
guideImage: {
width: '100%',
height: '100%',
},
guideImageIcon: {
position: 'absolute',
top: 8,
left: 8,
zIndex: 1,
},
guideImageIndicator: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
guideImageIndicatorError: {
backgroundColor: 'rgba(244, 67, 54, 0.1)',
},
guideDescription: {
backgroundColor: '#f8fafc',
borderRadius: 16,
padding: 16,
marginBottom: 24,
},
guideDescriptionText: {
fontSize: 14,
lineHeight: 22,
color: '#475569',
marginBottom: 8,
},
guideConfirmButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#FFB300',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
guideConfirmButtonGradient: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
guideConfirmButtonText: {
fontSize: 18,
fontWeight: '700',
color: '#FFFFFF',
},
});