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

@@ -7,7 +7,6 @@ import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
import type { MedicationForm, RepeatPattern } from '@/types/medication';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
@@ -151,10 +150,13 @@ export default function AddMedicationScreen() {
const [timesPickerValue, setTimesPickerValue] = useState(1);
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(null);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
const [timePickerVisible, setTimePickerVisible] = useState(false);
@@ -319,6 +321,7 @@ export default function AddMedicationScreen() {
medicationTimes: medicationTimes,
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
repeatPattern: 'daily' as RepeatPattern,
note: note.trim() || undefined,
};
@@ -333,16 +336,6 @@ export default function AddMedicationScreen() {
const today = dayjs().format('YYYY-MM-DD');
await dispatch(fetchMedicationRecords({ date: today }));
// 重新安排药品通知
try {
// 获取最新的药品列表
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
// 不影响添加药品的成功流程,只记录错误
}
// 成功提示
Alert.alert(
'添加成功',
@@ -531,6 +524,11 @@ export default function AddMedicationScreen() {
setEndDatePickerVisible(true);
}, [endDate]);
const openExpiryDatePicker = useCallback(() => {
setExpiryDatePickerValue(expiryDate || new Date());
setExpiryDatePickerVisible(true);
}, [expiryDate]);
const confirmStartDate = useCallback((date: Date) => {
// 验证开始日期不能早于今天
const today = new Date();
@@ -563,6 +561,22 @@ export default function AddMedicationScreen() {
setEndDatePickerVisible(false);
}, [startDate]);
const confirmExpiryDate = useCallback((date: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(date);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
setExpiryDate(date);
setExpiryDatePickerVisible(false);
}, []);
const openTimePicker = useCallback(
(index?: number) => {
try {
@@ -872,6 +886,32 @@ export default function AddMedicationScreen() {
</TouchableOpacity>
</View>
</View>
<View style={styles.inputGroup}>
<View style={styles.periodHeader}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
</View>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.dateRow,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openExpiryDatePicker}
>
<View style={styles.dateLeft}>
<Ionicons name="time-outline" size={16} color={colors.textSecondary} />
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
{expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'}
</ThemedText>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
);
case 3:
@@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() {
</View>
</Modal>
<Modal
visible={expiryDatePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setExpiryDatePickerVisible(false)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setExpiryDatePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<DateTimePicker
value={expiryDatePickerValue}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setExpiryDatePickerValue(date);
} else {
if (event.type === 'set' && date) {
confirmExpiryDate(date);
} else {
setExpiryDatePickerVisible(false);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable
onPress={() => setExpiryDatePickerVisible(false)}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmExpiryDate(expiryDatePickerValue)}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
<Modal
visible={endDatePickerVisible}
transparent