From 25b8e45af84e65325d8b56bf70b3944955477dc8 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 10 Nov 2025 10:02:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=9A=84=E7=94=A8=E8=8D=AF=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加了药物管理的核心功能,包括: - 药物列表展示和状态管理 - 添加新药物的完整流程 - 服药记录的创建和状态更新 - 药物管理界面,支持激活/停用操作 - Redux状态管理和API服务层 - 相关类型定义和辅助函数 主要文件: - app/(tabs)/medications.tsx - 主界面,集成Redux数据 - app/medications/add-medication.tsx - 添加药物流程 - app/medications/manage-medications.tsx - 药物管理界面 - store/medicationsSlice.ts - Redux状态管理 - services/medications.ts - API服务层 - types/medication.ts - 类型定义 --- app/(tabs)/medications.tsx | 238 ++-- app/medications/add-medication.tsx | 1437 ++++++++++++++++++++++ app/medications/manage-medications.tsx | 401 ++++++ components/DateSelector.tsx | 344 ++++-- components/medication/MedicationCard.tsx | 107 +- components/ui/IconSymbol.tsx | 2 + services/medications.ts | 311 +++++ store/index.ts | 2 + store/medicationsSlice.ts | 724 +++++++++++ types/medication.ts | 93 ++ utils/medicationHelpers.ts | 91 ++ 11 files changed, 3517 insertions(+), 233 deletions(-) create mode 100644 app/medications/add-medication.tsx create mode 100644 app/medications/manage-medications.tsx create mode 100644 services/medications.ts create mode 100644 store/medicationsSlice.ts create mode 100644 types/medication.ts create mode 100644 utils/medicationHelpers.ts diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index 174b440..fa0efff 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -1,17 +1,26 @@ import { DateSelector } from '@/components/DateSelector'; -import { MedicationCard, type Medication, type MedicationStatus } from '@/components/medication/MedicationCard'; +import { MedicationCard } from '@/components/medication/MedicationCard'; import { ThemedText } from '@/components/ThemedText'; import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate, selectMedicationsLoading } from '@/store/medicationsSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import { useFocusEffect } from '@react-navigation/native'; import dayjs, { Dayjs } from 'dayjs'; import 'dayjs/locale/zh-cn'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useMemo, useState } from 'react'; -import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { router } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; dayjs.locale('zh-cn'); @@ -20,133 +29,71 @@ type MedicationFilter = 'all' | 'taken' | 'missed'; type ThemeColors = (typeof Colors)[keyof typeof Colors]; -const MEDICATION_IMAGES = { - bottle: require('@/assets/images/icons/icon-healthy-diet.png'), - drops: require('@/assets/images/icons/icon-remind.png'), - vitamins: require('@/assets/images/icons/icon-blood-oxygen.png'), -}; - export default function MedicationsScreen() { + const dispatch = useAppDispatch(); const insets = useSafeAreaInsets(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colors: ThemeColors = Colors[theme]; const userProfile = useAppSelector((state) => state.user.profile); - const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDateIndex, setSelectedDateIndex] = useState(selectedDate.date() - 1); const [activeFilter, setActiveFilter] = useState('all'); - const scheduledMedications = useMemo(() => { - const today = dayjs(); - const todayKey = today.format('YYYY-MM-DD'); - const yesterdayKey = today.subtract(1, 'day').format('YYYY-MM-DD'); - const twoDaysAgoKey = today.subtract(2, 'day').format('YYYY-MM-DD'); + // 从 Redux 获取数据 + const selectedKey = selectedDate.format('YYYY-MM-DD'); + const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state)); + const loading = useAppSelector(selectMedicationsLoading); - return { - [todayKey]: [ - { - id: 'med-1', - name: 'Metformin', - dosage: '1 粒胶囊', - scheduledTime: '09:00', - frequency: '每日', - status: 'upcoming' as MedicationStatus, - image: MEDICATION_IMAGES.bottle, - }, - { - id: 'med-2', - name: 'Captopril', - dosage: '2 粒胶囊', - scheduledTime: '20:00', - frequency: '每日', - status: 'upcoming' as MedicationStatus, - image: MEDICATION_IMAGES.vitamins, - }, - { - id: 'med-3', - name: 'B 12', - dosage: '1 次注射', - scheduledTime: '22:00', - frequency: '每日', - status: 'taken' as MedicationStatus, - image: MEDICATION_IMAGES.vitamins, - }, - { - id: 'med-4', - name: 'I-DROP MGD', - dosage: '2 滴', - scheduledTime: '22:00', - frequency: '每日', - status: 'missed' as MedicationStatus, - image: MEDICATION_IMAGES.drops, - }, - { - id: 'med-5', - name: 'Niacin', - dosage: '0.5 片', - scheduledTime: '22:00', - frequency: '每日', - status: 'missed' as MedicationStatus, - image: MEDICATION_IMAGES.bottle, - }, - ], - [yesterdayKey]: [ - { - id: 'med-6', - name: 'B 12', - dosage: '1 次注射', - scheduledTime: '22:00', - frequency: '每日', - status: 'taken' as MedicationStatus, - image: MEDICATION_IMAGES.vitamins, - }, - ], - [twoDaysAgoKey]: [ - { - id: 'med-7', - name: 'I-DROP MGD', - dosage: '2 滴', - scheduledTime: '22:00', - frequency: '每日', - status: 'missed' as MedicationStatus, - image: MEDICATION_IMAGES.drops, - }, - { - id: 'med-8', - name: 'Niacin', - dosage: '0.5 片', - scheduledTime: '22:00', - frequency: '每日', - status: 'missed' as MedicationStatus, - image: MEDICATION_IMAGES.bottle, - }, - ], - } as Record; + const handleOpenAddMedication = useCallback(() => { + router.push('/medications/add-medication'); }, []); + const handleOpenMedicationManagement = useCallback(() => { + router.push('/medications/manage-medications'); + }, []); + + // 加载药物和记录数据 + useEffect(() => { + dispatch(fetchMedications()); + dispatch(fetchMedicationRecords({ date: selectedKey })); + }, [dispatch, selectedKey]); + + // 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据 + useFocusEffect( + useCallback(() => { + dispatch(fetchMedications({ isActive: true })); + dispatch(fetchMedicationRecords({ date: selectedKey })); + }, [dispatch, selectedKey]) + ); + useEffect(() => { setActiveFilter('all'); }, [selectedDate]); - const selectedKey = selectedDate.format('YYYY-MM-DD'); - const medicationsForDay = scheduledMedications[selectedKey] ?? []; + // 为每个药物添加默认图片(如果没有图片) + const medicationsWithImages = useMemo(() => { + return medicationsForDay.map((med: any) => ({ + ...med, + image: med.image || require('@/assets/images/icons/icon-healthy-diet.png'), // 默认使用瓶子图标 + })); + }, [medicationsForDay]); const filteredMedications = useMemo(() => { if (activeFilter === 'all') { - return medicationsForDay; + return medicationsWithImages; } - return medicationsForDay.filter((item) => item.status === activeFilter); - }, [activeFilter, medicationsForDay]); + return medicationsWithImages.filter((item: any) => item.status === activeFilter); + }, [activeFilter, medicationsWithImages]); const counts = useMemo(() => { - const taken = medicationsForDay.filter((item) => item.status === 'taken').length; - const missed = medicationsForDay.filter((item) => item.status === 'missed').length; + const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length; + const missed = medicationsWithImages.filter((item: any) => item.status === 'missed').length; return { - all: medicationsForDay.length, + all: medicationsWithImages.length, taken, missed, }; - }, [medicationsForDay]); + }, [medicationsWithImages]); const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME; const headerDateLabel = selectedDate.isSame(dayjs(), 'day') @@ -183,6 +130,47 @@ export default function MedicationsScreen() { 欢迎来到用药助手! + + + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + + + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + @@ -258,18 +246,10 @@ export default function MedicationsScreen() { 还未添加任何用药计划,快来补充吧。 - - - - 添加用药 - - ) : ( - {filteredMedications.map((item) => ( + {filteredMedications.map((item: any) => ( { + if (!HEX_COLOR_REGEX.test(hex)) { + return hex; + } + const normalized = hex.replace('#', ''); + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + const safeAlpha = Math.min(Math.max(alpha, 0), 1); + return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`; +}; + +// 表单数据接口(用于内部状态管理) +interface AddMedicationFormData { + name: string; + photoUrl?: string | null; + form: MedicationForm; + dosageValue: string; + dosageUnit: string; + timesPerDay: number; + medicationTimes: string[]; + startDate: string; + note: string; +} + +const FORM_OPTIONS: Array<{ id: MedicationForm; label: string; icon: keyof typeof MaterialCommunityIcons.glyphMap }> = [ + { id: 'capsule', label: '胶囊', icon: 'pill' }, + { id: 'pill', label: '药片', icon: 'tablet' }, + { id: 'injection', label: '注射', icon: 'needle' }, + { id: 'spray', label: '喷雾', icon: 'spray' }, + { id: 'drop', label: '滴剂', icon: 'eyedropper' }, + { id: 'syrup', label: '糖浆', icon: 'bottle-tonic' }, + { id: 'other', label: '其他', icon: 'dots-horizontal' }, +]; + +const DOSAGE_UNITS = ['片', '粒', '毫升', '滴', '喷', '勺']; +const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1); +const STEP_TITLES = ['药品名称', '剂型与剂量', '服药频率', '服药时间', '备注']; +const STEP_DESCRIPTIONS = [ + '为药物命名并上传包装照片,方便识别', + '选择药片类型并填写每次的用药剂量', + '设置用药频率以及每日次数', + '添加并管理每天的提醒时间', + '填写备注或医生叮嘱(可选)', +]; +const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00']; + +const formatTime = (date: Date) => dayjs(date).format('HH:mm'); +const getDefaultTimeByIndex = (index: number) => DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length]; +const createDateFromTime = (time: string) => { + const [hour, minute] = time.split(':').map((val) => parseInt(val, 10)); + const next = new Date(); + next.setHours(hour || 0, minute || 0, 0, 0); + return next; +}; + +export default function AddMedicationScreen() { + const dispatch = useAppDispatch(); + const insets = useSafeAreaInsets(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colors: ThemeColors = Colors[theme]; + const glassAvailable = isLiquidGlassAvailable(); + const totalSteps = STEP_TITLES.length; + const [currentStep, setCurrentStep] = useState(0); + const [medicationName, setMedicationName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { upload, uploading } = useCosUpload({ prefix: 'images/medications' }); + const softBorderColor = useMemo(() => withAlpha(colors.border, 0.45), [colors.border]); + const fadedBorderFill = useMemo(() => withAlpha(colors.border, 0.2), [colors.border]); + const glassPrimaryTint = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.55 : 0.45), [colors.primary, theme]); + const glassDisabledTint = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.45 : 0.6), [colors.border, theme]); + const glassPrimaryBackground = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.35 : 0.7), [colors.primary, theme]); + const glassDisabledBackground = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.35 : 0.5), [colors.border, theme]); + + const [photoPreview, setPhotoPreview] = useState(null); + const [photoUrl, setPhotoUrl] = useState(null); + const [selectedForm, setSelectedForm] = useState('capsule'); + const [dosageValue, setDosageValue] = useState('1'); + const [dosageUnit, setDosageUnit] = useState(DOSAGE_UNITS[0]); + const [unitPickerVisible, setUnitPickerVisible] = useState(false); + const [unitPickerValue, setUnitPickerValue] = useState(DOSAGE_UNITS[0]); + const [timesPerDay, setTimesPerDay] = useState(1); + const [timesPickerVisible, setTimesPickerVisible] = useState(false); + const [timesPickerValue, setTimesPickerValue] = useState(1); + const [startDate, setStartDate] = useState(new Date()); + const [datePickerVisible, setDatePickerVisible] = useState(false); + const [datePickerValue, setDatePickerValue] = useState(new Date()); + const [medicationTimes, setMedicationTimes] = useState([DEFAULT_TIME_PRESETS[0]]); + const [timePickerVisible, setTimePickerVisible] = useState(false); + const [timePickerDate, setTimePickerDate] = useState(createDateFromTime(DEFAULT_TIME_PRESETS[0])); + const [editingTimeIndex, setEditingTimeIndex] = useState(null); + const [note, setNote] = useState(''); + const [dictationActive, setDictationActive] = useState(false); + const [dictationLoading, setDictationLoading] = useState(false); + const isDictationSupported = Platform.OS === 'ios'; + + useEffect(() => { + setMedicationTimes((prev) => { + if (timesPerDay > prev.length) { + const next = [...prev]; + while (next.length < timesPerDay) { + next.push(getDefaultTimeByIndex(next.length)); + } + return next; + } + if (timesPerDay < prev.length) { + return prev.slice(0, timesPerDay); + } + return prev; + }); + }, [timesPerDay]); + + const appendDictationResult = useCallback( + (text: string) => { + const clean = text.trim(); + if (!clean) return; + setNote((prev) => { + if (!prev) { + return clean; + } + return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`; + }); + }, + [setNote] + ); + + const stepTitle = STEP_TITLES[currentStep] ?? STEP_TITLES[0]; + const stepDescription = STEP_DESCRIPTIONS[currentStep] ?? ''; + + const canProceed = useMemo(() => { + switch (currentStep) { + case 0: + return medicationName.trim().length > 0; + case 1: + return Number(dosageValue) > 0 && !!dosageUnit && !!selectedForm; + case 2: + return timesPerDay > 0; + case 3: + return medicationTimes.length > 0; + default: + return true; + } + }, [currentStep, dosageUnit, dosageValue, medicationName, medicationTimes.length, selectedForm, timesPerDay]); + + useEffect(() => { + if (!isDictationSupported) { + return; + } + + Voice.onSpeechStart = () => { + setDictationActive(true); + setDictationLoading(false); + }; + + Voice.onSpeechEnd = () => { + setDictationActive(false); + setDictationLoading(false); + }; + + Voice.onSpeechResults = (event: any) => { + const recognized = event?.value?.[0]; + if (recognized) { + appendDictationResult(recognized); + } + }; + + Voice.onSpeechError = (error: any) => { + console.log('[MEDICATION] voice error', error); + setDictationActive(false); + setDictationLoading(false); + Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试'); + }; + + return () => { + Voice.destroy() + .then(() => { + Voice.removeAllListeners(); + }) + .catch(() => {}); + }; + }, [appendDictationResult, isDictationSupported]); + + const handleNext = useCallback(async () => { + if (!canProceed) return; + + // 如果不是最后一步,继续下一步 + if (currentStep < totalSteps - 1) { + setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1)); + return; + } + + // 最后一步:提交药物数据 + setIsSubmitting(true); + + try { + // 构建药物数据,符合 CreateMedicationDto 接口 + const medicationData = { + name: medicationName.trim(), + photoUrl: photoUrl || undefined, + form: selectedForm, + dosageValue: Number(dosageValue), + dosageUnit: dosageUnit, + timesPerDay: timesPerDay, + medicationTimes: medicationTimes, + startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式 + repeatPattern: 'daily' as RepeatPattern, + note: note.trim() || undefined, + }; + + // 调用 Redux action 创建药物 + const result = await dispatch(createMedicationAction(medicationData)).unwrap(); + + // 刷新药物列表和记录数据,确保返回主界面能看到新数据 + await dispatch(fetchMedications({ isActive: true })); + + // 获取今天的记录,确保新添加的药物记录能显示 + const today = dayjs().format('YYYY-MM-DD'); + await dispatch(fetchMedicationRecords({ date: today })); + + // 成功提示 + Alert.alert( + '添加成功', + `已成功添加药物"${medicationName}"`, + [ + { + text: '确定', + onPress: () => router.back(), + }, + ] + ); + } catch (error) { + console.error('[MEDICATION] 创建药物失败', error); + Alert.alert( + '添加失败', + error instanceof Error ? error.message : '创建药物时发生错误,请稍后重试', + [{ text: '确定' }] + ); + } finally { + setIsSubmitting(false); + } + }, [ + canProceed, + currentStep, + totalSteps, + medicationName, + photoUrl, + selectedForm, + dosageValue, + dosageUnit, + medicationTimes, + startDate, + note, + dispatch, + ]); + + const handlePrev = useCallback(() => { + if (currentStep === 0) return; + setCurrentStep((prev) => Math.max(prev - 1, 0)); + }, [currentStep]); + + const handleDictationPress = useCallback(async () => { + if (!isDictationSupported || dictationLoading) { + return; + } + + try { + if (dictationActive) { + setDictationLoading(true); + await Voice.stop(); + setDictationLoading(false); + return; + } + + setDictationLoading(true); + try { + await Voice.stop(); + } catch { + // no-op: safe to ignore if not already recording + } + await Voice.start('zh-CN'); + } catch (error) { + console.log('[MEDICATION] unable to start dictation', error); + setDictationLoading(false); + Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试'); + } + }, [dictationActive, dictationLoading, isDictationSupported]); + + const handleTakePhoto = useCallback(async () => { + try { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (permission.status !== 'granted') { + Alert.alert('权限不足', '需要相机权限以拍摄药品照片'); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + allowsEditing: true, + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.9, + }); + + if (result.canceled || !result.assets?.length) { + return; + } + + const asset = result.assets[0]; + setPhotoPreview(asset.uri); + setPhotoUrl(null); + + try { + const { url } = await upload( + { uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' }, + { prefix: 'images/medications' } + ); + setPhotoUrl(url); + } catch (uploadError) { + console.error('[MEDICATION] 图片上传失败', uploadError); + Alert.alert('上传失败', '图片上传失败,请稍后重试'); + } + } catch (error) { + console.error('[MEDICATION] 拍照失败', error); + Alert.alert('拍照失败', '无法打开相机,请稍后再试'); + } + }, [upload]); + + const handleRemovePhoto = useCallback(() => { + setPhotoPreview(null); + setPhotoUrl(null); + }, []); + + const openDatePicker = useCallback(() => { + setDatePickerValue(startDate); + setDatePickerVisible(true); + }, [startDate]); + + const confirmStartDate = useCallback((date: Date) => { + setStartDate(date); + setDatePickerVisible(false); + }, []); + + const openTimePicker = useCallback( + (index?: number) => { + if (typeof index === 'number') { + setEditingTimeIndex(index); + setTimePickerDate(createDateFromTime(medicationTimes[index])); + } else { + setEditingTimeIndex(null); + setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length))); + } + setTimePickerVisible(true); + }, + [medicationTimes] + ); + + const confirmTime = useCallback( + (date: Date) => { + const nextValue = formatTime(date); + setMedicationTimes((prev) => { + if (editingTimeIndex == null) { + return [...prev, nextValue]; + } + return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time)); + }); + setTimePickerVisible(false); + setEditingTimeIndex(null); + }, + [editingTimeIndex] + ); + + const removeTime = useCallback((index: number) => { + setMedicationTimes((prev) => { + if (prev.length === 1) { + return prev; + } + return prev.filter((_, idx) => idx !== index); + }); + }, []); + + useEffect(() => { + setUnitPickerVisible(false); + setTimesPickerVisible(false); + }, [currentStep]); + + const openUnitPicker = useCallback(() => { + setUnitPickerValue(dosageUnit); + setUnitPickerVisible(true); + }, [dosageUnit]); + + const closeUnitPicker = useCallback(() => { + setUnitPickerVisible(false); + }, []); + + const confirmUnitPicker = useCallback(() => { + setDosageUnit(unitPickerValue); + setUnitPickerVisible(false); + }, [unitPickerValue]); + + const openTimesPicker = useCallback(() => { + setTimesPickerValue(timesPerDay); + setTimesPickerVisible(true); + }, [timesPerDay]); + + const closeTimesPicker = useCallback(() => { + setTimesPickerVisible(false); + }, []); + + const confirmTimesPicker = useCallback(() => { + setTimesPerDay(timesPickerValue); + setTimesPickerVisible(false); + }, [timesPickerValue]); + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + + + + + + + + {photoPreview ? ( + <> + + + + + {uploading ? '上传中…' : '重新拍摄'} + + + + + + + ) : ( + + + + + 拍照上传药品图片 + 辅助识别药品包装,更易区分 + + )} + {uploading && ( + + + 正在上传 + + )} + + + ); + case 1: + return ( + + + 每次剂量 + + + + + {dosageUnit} + + + + + + + + 类型 + + {FORM_OPTIONS.map((option) => { + const active = selectedForm === option.id; + return ( + setSelectedForm(option.id)} + activeOpacity={0.9} + > + + + + {option.label} + + ); + })} + + + + ); + case 2: + return ( + + + + 每日次数 + + {`${timesPerDay} 次/日`} + + + + + ); + case 3: + return ( + + + 每日提醒时间 + + {medicationTimes.map((time, index) => ( + + openTimePicker(index)}> + + {time} + + removeTime(index)} disabled={medicationTimes.length === 1} hitSlop={12}> + + + + ))} + openTimePicker()}> + + 添加时间 + + + + + ); + case 4: + return ( + + + 备注 + + + {isDictationSupported && ( + + {dictationLoading ? ( + + ) : ( + + )} + + )} + + + + ); + default: + return null; + } + }; + + const showDateField = currentStep === 2; + + return ( + + router.back()} + withSafeTop={false} + transparent + variant="elevated" + /> + + + + + + {Array.from({ length: totalSteps }).map((_, index) => { + const isActive = index <= currentStep; + return ( + + ); + })} + + + + {stepTitle} + {stepDescription} + + + {renderStepContent()} + + + {showDateField && ( + + + + + 开始日期 + + {dayjs(startDate).format('YYYY 年 MM 月 DD 日')} + + + + + + )} + + + {currentStep > 0 && ( + + 上一步 + + )} + {glassAvailable ? ( + + + {isSubmitting ? ( + + ) : ( + + {currentStep === totalSteps - 1 ? '完成' : '下一步'} + + )} + + + ) : ( + + {isSubmitting ? ( + + ) : ( + + {currentStep === totalSteps - 1 ? '完成' : '下一步'} + + )} + + )} + + + + + + + setDatePickerVisible(false)} + > + setDatePickerVisible(false)} /> + + { + if (Platform.OS === 'ios') { + if (date) setDatePickerValue(date); + } else { + if (event.type === 'set' && date) { + confirmStartDate(date); + } else { + setDatePickerVisible(false); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + setDatePickerVisible(false)} + style={[styles.modalBtn, { borderColor: softBorderColor }]} + > + 取消 + + confirmStartDate(datePickerValue)} + style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]} + > + 确定 + + + )} + + + + setTimePickerVisible(false)} + > + setTimePickerVisible(false)} /> + + { + if (Platform.OS === 'ios') { + if (date) setTimePickerDate(date); + } else { + if (event.type === 'set' && date) { + confirmTime(date); + } else { + setTimePickerVisible(false); + setEditingTimeIndex(null); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + { + setTimePickerVisible(false); + setEditingTimeIndex(null); + }} + style={[styles.modalBtn, { borderColor: softBorderColor }]} + > + 取消 + + confirmTime(timePickerDate)} + style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]} + > + 确定 + + + )} + + + + + + + 选择每日次数 + setTimesPickerValue(Number(value))} + itemStyle={styles.unitPickerItem} + style={styles.unitPicker} + > + {TIMES_PER_DAY_OPTIONS.map((count) => ( + + ))} + + + + 取消 + + + 确定 + + + + + + + + + 选择剂量单位 + setUnitPickerValue(String(value))} + itemStyle={styles.unitPickerItem} + style={styles.unitPicker} + > + {DOSAGE_UNITS.map((unit) => ( + + ))} + + + + 取消 + + + 确定 + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + flex: { + flex: 1, + }, + pageContent: { + gap: 24, + }, + formSurface: { + paddingHorizontal: 24, + gap: 20, + width: '100%', + }, + stepIndicator: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + }, + stepSegment: { + flex: 1, + height: 4, + borderRadius: 2, + }, + titleBlock: { + gap: 6, + }, + modalTitle: { + fontSize: 22, + fontWeight: '600', + }, + modalSubtitle: { + fontSize: 14, + lineHeight: 20, + }, + contentContainer: { + paddingBottom: 16, + gap: 20, + }, + stepSection: { + gap: 20, + }, + searchField: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + borderRadius: 16, + borderWidth: 1, + height: 56, + gap: 12, + }, + searchInput: { + flex: 1, + fontSize: 16, + paddingVertical: 0, + }, + photoCard: { + borderWidth: 1, + borderRadius: 20, + height: 180, + overflow: 'hidden', + justifyContent: 'center', + }, + photoPlaceholder: { + alignItems: 'center', + justifyContent: 'center', + gap: 10, + paddingHorizontal: 20, + }, + photoIconBadge: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + photoTitle: { + fontSize: 16, + fontWeight: '600', + }, + photoSubtitle: { + fontSize: 13, + textAlign: 'center', + }, + photoPreview: { + width: '100%', + height: '100%', + }, + photoOverlay: { + position: 'absolute', + right: 16, + top: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: 'rgba(15, 23, 42, 0.55)', + borderRadius: 999, + }, + photoOverlayText: { + color: '#fff', + fontSize: 13, + fontWeight: '600', + }, + photoUploadingIndicator: { + position: 'absolute', + bottom: 16, + left: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: 'rgba(15, 23, 42, 0.6)', + borderRadius: 999, + }, + uploadingText: { + fontSize: 12, + fontWeight: '600', + }, + photoRemoveBtn: { + position: 'absolute', + right: 12, + bottom: 12, + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.85)', + alignItems: 'center', + justifyContent: 'center', + }, + inputGroup: { + gap: 12, + }, + groupLabel: { + fontSize: 14, + fontWeight: '600', + letterSpacing: 0.2, + }, + dosageField: { + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 12, + position: 'relative', + overflow: 'visible', + }, + dosageInput: { + fontSize: 28, + fontWeight: '600', + flex: 1, + paddingVertical: 0, + }, + dosageRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + unitSelector: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + unitSelectorText: { + fontSize: 14, + fontWeight: '600', + }, + unitPicker: { + width: '100%', + }, + unitPickerItem: { + fontSize: 16, + }, + formGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + formOption: { + flexBasis: '30%', + borderWidth: 1, + borderRadius: 16, + paddingVertical: 6, + alignItems: 'center', + justifyContent: 'center', + }, + formIconBadge: { + width: 28, + height: 28, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + formLabel: { + fontSize: 11, + fontWeight: '600', + }, + frequencyRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + }, + frequencyValue: { + fontSize: 18, + fontWeight: '600', + }, + stepperBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + frequencyLabel: { + fontSize: 16, + fontWeight: '600', + }, + unitSwitch: { + flexDirection: 'row', + gap: 12, + marginTop: 10, + }, + unitOption: { + flex: 1, + paddingVertical: 12, + borderWidth: 1, + borderRadius: 14, + alignItems: 'center', + }, + timeList: { + gap: 12, + }, + timeItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 12, + }, + timeValue: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + timeText: { + fontSize: 18, + fontWeight: '600', + }, + addTimeButton: { + borderWidth: 1, + borderRadius: 16, + paddingVertical: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + }, + addTimeLabel: { + fontSize: 14, + fontWeight: '600', + }, + noteInput: { + borderWidth: 1, + borderRadius: 20, + padding: 16, + paddingRight: 72, + paddingBottom: 56, + minHeight: 140, + textAlignVertical: 'top', + fontSize: 15, + lineHeight: 22, + }, + noteInputWrapper: { + position: 'relative', + }, + noteVoiceButton: { + position: 'absolute', + right: 16, + bottom: 16, + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + }, + footer: { + gap: 12, + }, + startDateRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 12, + }, + startDateLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + startDateLabel: { + fontSize: 12, + }, + startDateValue: { + fontSize: 16, + fontWeight: '600', + }, + footerButtons: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + }, + secondaryBtn: { + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 18, + borderWidth: 1, + borderColor: '#E2E8F0', + }, + secondaryBtnText: { + fontSize: 15, + fontWeight: '600', + color: '#475569', + }, + primaryBtn: { + flex: 1, + paddingVertical: 16, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + primaryBtnWrapper: { + flex: 1, + }, + primaryBtnWrapperDisabled: { + opacity: 0.6, + }, + glassPrimaryBtn: { + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.2)', + }, + primaryBtnFallback: { + justifyContent: 'center', + }, + primaryBtnText: { + fontSize: 16, + fontWeight: '700', + }, + pickerBackdrop: { + flex: 1, + backgroundColor: 'rgba(15, 23, 42, 0.4)', + }, + pickerSheet: { + position: 'absolute', + left: 20, + right: 20, + bottom: 40, + borderRadius: 24, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.2, + shadowRadius: 20, + elevation: 8, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + marginTop: 16, + }, + modalBtn: { + flex: 1, + borderRadius: 16, + paddingVertical: 12, + alignItems: 'center', + borderWidth: 1, + borderColor: '#E2E8F0', + }, + modalBtnPrimary: { + backgroundColor: '#4F46E5', + borderColor: 'transparent', + }, + modalBtnText: { + fontSize: 15, + fontWeight: '600', + color: '#475569', + }, + modalBtnTextPrimary: { + color: '#fff', + }, +}); diff --git a/app/medications/manage-medications.tsx b/app/medications/manage-medications.tsx new file mode 100644 index 0000000..5cdcc1e --- /dev/null +++ b/app/medications/manage-medications.tsx @@ -0,0 +1,401 @@ +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { IconSymbol } from '@/components/ui/IconSymbol'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; +import { + fetchMedications, + selectMedications, + selectMedicationsLoading, + updateMedicationAction, +} from '@/store/medicationsSlice'; +import type { Medication, MedicationForm } from '@/types/medication'; +import { useFocusEffect } from '@react-navigation/native'; +import dayjs from 'dayjs'; +import { Image } from 'expo-image'; +import { router } from 'expo-router'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Switch, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type FilterType = 'all' | 'active' | 'inactive'; + +const FORM_LABELS: Record = { + capsule: '胶囊', + pill: '药片', + injection: '注射', + spray: '喷雾', + drop: '滴剂', + syrup: '糖浆', + other: '其他', +}; + +const FILTER_CONFIG: Array<{ key: FilterType; label: string }> = [ + { key: 'all', label: '全部' }, + { key: 'active', label: '进行中' }, + { key: 'inactive', label: '已停用' }, +]; + +const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png'); + +export default function ManageMedicationsScreen() { + const dispatch = useAppDispatch(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colors = Colors[theme]; + const safeAreaTop = useSafeAreaTop(); + const insets = useSafeAreaInsets(); + const medications = useAppSelector(selectMedications); + const loading = useAppSelector(selectMedicationsLoading); + const [activeFilter, setActiveFilter] = useState('all'); + const [pendingMedicationId, setPendingMedicationId] = useState(null); + + const updateLoading = loading.update; + const listLoading = loading.medications && medications.length === 0; + + useFocusEffect( + useCallback(() => { + dispatch(fetchMedications()); + }, [dispatch]) + ); + + // 优化:使用更精确的依赖项,只有当药品数量或激活状态改变时才重新计算 + const medicationsHash = useMemo(() => { + return medications.map(m => `${m.id}-${m.isActive}`).join('|'); + }, [medications]); + + const counts = useMemo>(() => { + const active = medications.filter((med) => med.isActive).length; + const inactive = medications.length - active; + return { + all: medications.length, + active, + inactive, + }; + }, [medicationsHash]); + + const filteredMedications = useMemo(() => { + switch (activeFilter) { + case 'active': + return medications.filter((med) => med.isActive); + case 'inactive': + return medications.filter((med) => !med.isActive); + default: + return medications; + } + }, [activeFilter, medicationsHash]); + + const handleToggleMedication = useCallback( + async (medication: Medication, nextValue: boolean) => { + if (pendingMedicationId) return; + try { + setPendingMedicationId(medication.id); + await dispatch( + updateMedicationAction({ + id: medication.id, + isActive: nextValue, + }) + ).unwrap(); + } catch (error) { + console.error('更新药物状态失败', error); + Alert.alert('操作失败', '切换药物状态时发生问题,请稍后重试。'); + } finally { + setPendingMedicationId(null); + } + }, + [dispatch, pendingMedicationId] + ); + + // 创建独立的药品卡片组件,使用 React.memo 优化渲染 + const MedicationCard = React.memo(({ medication }: { medication: Medication }) => { + const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim(); + const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`; + const startDateLabel = dayjs(medication.startDate).isValid() + ? dayjs(medication.startDate).format('M月D日') + : '未知日期'; + const reminderLabel = medication.medicationTimes?.length + ? medication.medicationTimes.join('、') + : `${medication.timesPerDay} 次/日`; + + return ( + + + + + {medication.name} + + {frequencyLabel} + + + {`开始于 ${startDateLabel} | 提醒:${reminderLabel}`} + + + + handleToggleMedication(medication, value)} + disabled={updateLoading || pendingMedicationId === medication.id} + trackColor={{ false: '#D9D9D9', true: colors.primary }} + thumbColor={medication.isActive ? '#fff' : '#fff'} + ios_backgroundColor="#D9D9D9" + /> + + ); + }, (prevProps, nextProps) => { + // 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染 + return ( + prevProps.medication.id === nextProps.medication.id && + prevProps.medication.isActive === nextProps.medication.isActive && + prevProps.medication.name === nextProps.medication.name && + prevProps.medication.photoUrl === nextProps.medication.photoUrl + ); + }); + + MedicationCard.displayName = 'MedicationCard'; + + const renderMedicationCard = useCallback( + (medication: Medication) => { + return ; + }, + [handleToggleMedication, pendingMedicationId, updateLoading, colors] + ); + + return ( + + router.back()} + variant="minimal" + transparent + /> + + + + + + + 我的用药 + + 管理所有药品的状态与提醒 + + + router.push('/medications/add-medication')} + > + + + + + + {FILTER_CONFIG.map((filter) => { + const isActive = filter.key === activeFilter; + return ( + setActiveFilter(filter.key)} + > + + {filter.label} + + + + {counts[filter.key] ?? 0} + + + + ); + })} + + + {listLoading ? ( + + + 正在载入药品信息... + + ) : filteredMedications.length === 0 ? ( + + + 暂无药品 + + 还没有相关药品记录,点击右上角添加 + + + ) : ( + {filteredMedications.map(renderMedicationCard)} + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingHorizontal: 20, + gap: 20, + }, + pageHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + title: { + fontSize: 26, + fontWeight: '600', + }, + subtitle: { + marginTop: 6, + fontSize: 14, + }, + addButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, + segmented: { + flexDirection: 'row', + padding: 6, + borderRadius: 20, + gap: 6, + }, + segmentButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 16, + paddingVertical: 10, + gap: 8, + }, + segmentLabel: { + fontSize: 15, + fontWeight: '600', + }, + segmentBadge: { + minWidth: 28, + paddingHorizontal: 8, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + segmentBadgeLabel: { + fontSize: 12, + fontWeight: '700', + }, + list: { + gap: 14, + }, + card: { + borderRadius: 22, + padding: 14, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 6, + shadowOffset: { width: 0, height: 4 }, + elevation: 1, + }, + cardInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + flex: 1, + }, + cardImage: { + width: 52, + height: 52, + borderRadius: 16, + backgroundColor: '#F2F2F2', + }, + cardTexts: { + flex: 1, + gap: 4, + }, + cardTitle: { + fontSize: 16, + fontWeight: '600', + }, + cardMeta: { + fontSize: 13, + }, + loading: { + borderRadius: 22, + paddingVertical: 32, + alignItems: 'center', + gap: 12, + }, + loadingText: { + fontSize: 14, + }, + empty: { + borderRadius: 22, + paddingVertical: 32, + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + }, + emptyImage: { + width: 120, + height: 120, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + }, + emptySubtitle: { + fontSize: 14, + textAlign: 'center', + }, +}); diff --git a/components/DateSelector.tsx b/components/DateSelector.tsx index f40c11f..38ccb28 100644 --- a/components/DateSelector.tsx +++ b/components/DateSelector.tsx @@ -2,16 +2,17 @@ import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/d import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; import dayjs from 'dayjs'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import React, { useEffect, useRef, useState } from 'react'; import { - Modal, + Animated, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; export interface DateSelectorProps { @@ -53,6 +54,9 @@ export const DateSelector: React.FC = ({ const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份 const selectedIndex = externalSelectedIndex ?? internalSelectedIndex; + + // Liquid Glass 可用性检查 + const isGlassAvailable = isLiquidGlassAvailable(); // 获取日期数据 const days = getMonthDaysZh(currentMonth); @@ -78,6 +82,9 @@ export const DateSelector: React.FC = ({ // 日历弹窗相关 const [datePickerVisible, setDatePickerVisible] = useState(false); const [pickerDate, setPickerDate] = useState(new Date()); + + // 动画值 + const fadeAnim = useRef(new Animated.Value(0)).current; // 滚动到指定索引 const scrollToIndex = (index: number, animated = true) => { @@ -113,7 +120,14 @@ export const DateSelector: React.FC = ({ if (scrollWidth > 0 && autoScrollToSelected) { scrollToIndex(selectedIndex, true); } - }, [scrollWidth, selectedIndex, autoScrollToSelected]); + + // 淡入动画 + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + }, [scrollWidth, selectedIndex, autoScrollToSelected, fadeAnim]); // 当选中索引变化时,滚动到对应位置 useEffect(() => { @@ -227,68 +241,122 @@ export const DateSelector: React.FC = ({ {!isSelectedDateToday() && ( - 回到今天 + {isGlassAvailable ? ( + + 回到今天 + + ) : ( + + 回到今天 + + )} )} {showCalendarIcon && ( - + {isGlassAvailable ? ( + + + + ) : ( + + + + )} )} )} - setScrollWidth(e.nativeEvent.layout.width)} - style={style} - > + + setScrollWidth(e.nativeEvent.layout.width)} + style={style} + > {days.map((d, i) => { const selected = i === selectedIndex; const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day'); return ( - !isFutureDate && handleDateSelect(i)} - activeOpacity={isFutureDate ? 1 : 0.8} disabled={isFutureDate} + style={({ pressed }) => [ + !isFutureDate && pressed && styles.dayPillPressed + ]} > - - {d.weekdayZh} - - - {d.dayOfMonth} - - + {selected && !isFutureDate ? ( + isGlassAvailable ? ( + + + {d.weekdayZh} + + + {d.dayOfMonth} + + + ) : ( + + + {d.weekdayZh} + + + {d.dayOfMonth} + + + ) + ) : ( + + + {d.weekdayZh} + + + {d.dayOfMonth} + + + )} + ); })} - + + {/* 日历选择弹窗 */} = ({ onRequestClose={closeDatePicker} > - - { - if (Platform.OS === 'ios') { - if (date) setPickerDate(date); - } else { - if (event.type === 'set' && date) { - onConfirmDate(date); + {isGlassAvailable ? ( + + { + if (Platform.OS === 'ios') { + if (date) setPickerDate(date); } else { - closeDatePicker(); + if (event.type === 'set' && date) { + onConfirmDate(date); + } else { + closeDatePicker(); + } } - } - }} - /> - {Platform.OS === 'ios' && ( - - - 取消 - - { - onConfirmDate(pickerDate); - }} style={[styles.modalBtn, styles.modalBtnPrimary]}> - 确定 - - - )} - + }} + /> + {Platform.OS === 'ios' && ( + + + 取消 + + { + onConfirmDate(pickerDate); + }} style={[styles.modalBtn, styles.modalBtnPrimary]}> + 确定 + + + )} + + ) : ( + + { + if (Platform.OS === 'ios') { + if (date) setPickerDate(date); + } else { + if (event.type === 'set' && date) { + onConfirmDate(date); + } else { + closeDatePicker(); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + + 取消 + + { + onConfirmDate(pickerDate); + }} style={[styles.modalBtn, styles.modalBtnPrimary]}> + 确定 + + + )} + + )} ); @@ -351,26 +460,39 @@ const styles = StyleSheet.create({ alignItems: 'center', }, monthTitle: { - fontSize: 20, + fontSize: 22, fontWeight: '800', - color: '#192126', + color: '#1a1a1a', + letterSpacing: -0.5, }, calendarIconButton: { padding: 4, borderRadius: 6, - marginLeft: 4 + marginLeft: 4, + overflow: 'hidden', + }, + calendarIconFallback: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.3)', }, todayButton: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 12, - backgroundColor: '#EEF2FF', marginRight: 8, + overflow: 'hidden', + }, + todayButtonFallback: { + backgroundColor: '#EEF2FF', + borderWidth: 1, + borderColor: 'rgba(124, 58, 237, 0.2)', }, todayButtonText: { fontSize: 12, - fontWeight: '600', - color: '#4C1D95', + fontWeight: '700', + color: '#7c3aed', + letterSpacing: 0.2, }, daysContainer: { paddingBottom: 8, @@ -386,17 +508,24 @@ const styles = StyleSheet.create({ borderRadius: 24, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, dayPillNormal: { backgroundColor: 'transparent', }, - dayPillSelected: { + dayPillPressed: { + opacity: 0.8, + transform: [{ scale: 0.96 }], + }, + dayPillSelectedFallback: { backgroundColor: '#FFFFFF', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.5)', }, dayPillDisabled: { backgroundColor: 'transparent', @@ -405,39 +534,47 @@ const styles = StyleSheet.create({ dayLabel: { fontSize: 11, fontWeight: '700', - color: 'gray', + color: '#8e8e93', marginBottom: 2, + letterSpacing: 0.1, }, dayLabelSelected: { - color: '#192126', + color: '#1a1a1a', + fontWeight: '800', }, dayLabelDisabled: { - color: 'gray', + color: '#c7c7cc', }, dayDate: { - fontSize: 12, - fontWeight: '600', - color: 'gray', + fontSize: 13, + fontWeight: '700', + color: '#8e8e93', + letterSpacing: -0.2, }, dayDateSelected: { - color: '#192126', + color: '#1a1a1a', + fontWeight: '800', }, dayDateDisabled: { - color: 'gray', + color: '#c7c7cc', }, modalBackdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(0,0,0,0.3)', }, modalSheet: { position: 'absolute', left: 0, right: 0, bottom: 0, - padding: 16, - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, + padding: 20, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 10, }, modalActions: { flexDirection: 'row', @@ -446,20 +583,35 @@ const styles = StyleSheet.create({ gap: 12, }, modalBtn: { - paddingHorizontal: 14, + paddingHorizontal: 16, paddingVertical: 10, - borderRadius: 10, - backgroundColor: '#F1F5F9', + borderRadius: 12, + backgroundColor: '#f8fafc', + borderWidth: 1, + borderColor: '#e2e8f0', + minWidth: 80, + alignItems: 'center', }, modalBtnPrimary: { - backgroundColor: '#7a5af8', + backgroundColor: '#7c3aed', + borderWidth: 1, + borderColor: '#7c3aed', + shadowColor: '#7c3aed', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 3, }, modalBtnText: { - color: '#334155', + color: '#475569', fontWeight: '700', + fontSize: 14, + letterSpacing: 0.1, }, modalBtnTextPrimary: { color: '#FFFFFF', fontWeight: '700', + fontSize: 14, + letterSpacing: 0.1, }, }); diff --git a/components/medication/MedicationCard.tsx b/components/medication/MedicationCard.tsx index adf33ec..6e65502 100644 --- a/components/medication/MedicationCard.tsx +++ b/components/medication/MedicationCard.tsx @@ -1,33 +1,94 @@ import { ThemedText } from '@/components/ThemedText'; +import { useAppDispatch } from '@/hooks/redux'; +import { takeMedicationAction } from '@/store/medicationsSlice'; +import type { MedicationDisplayItem } from '@/types/medication'; import { Ionicons } from '@expo/vector-icons'; import dayjs, { Dayjs } from 'dayjs'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; -import React from 'react'; -import { StyleSheet, TouchableOpacity, View } from 'react-native'; - -export type MedicationStatus = 'upcoming' | 'taken' | 'missed'; - -export type Medication = { - id: string; - name: string; - dosage: string; - scheduledTime: string; - frequency: string; - status: MedicationStatus; - image: any; -}; +import React, { useState } from 'react'; +import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'; export type MedicationCardProps = { - medication: Medication; + medication: MedicationDisplayItem; colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors]; selectedDate: Dayjs; }; export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) { + const dispatch = useAppDispatch(); + const [isSubmitting, setIsSubmitting] = useState(false); + const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`); const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute'); + /** + * 处理服药操作 + */ + const handleTakeMedication = async () => { + // 检查 recordId 是否存在 + if (!medication.recordId || isSubmitting) { + return; + } + + // 判断是否早于服药时间1小时以上 + if (timeDiffMinutes > 60) { + // 显示二次确认弹窗 + Alert.alert( + '尚未到服药时间', + `该用药计划在 ${medication.scheduledTime},现在还早于1小时以上。\n\n是否确认已服用此药物?`, + [ + { + text: '取消', + style: 'cancel', + onPress: () => { + // 用户取消,不执行任何操作 + console.log('用户取消提前服药'); + }, + }, + { + text: '确认已服用', + style: 'default', + onPress: () => { + // 用户确认,执行服药逻辑 + executeTakeMedication(medication.recordId!); + }, + }, + ] + ); + } else { + // 在正常时间范围内,直接执行服药逻辑 + executeTakeMedication(medication.recordId); + } + }; + + /** + * 执行服药操作(提取公共逻辑) + */ + const executeTakeMedication = async (recordId: string) => { + setIsSubmitting(true); + + try { + // 调用 Redux action 标记为已服用 + await dispatch(takeMedicationAction({ + recordId: recordId, + actualTime: new Date().toISOString(), + })).unwrap(); + + // 可选:显示成功提示 + // Alert.alert('服药成功', '已记录本次服药'); + } catch (error) { + console.error('[MEDICATION_CARD] 服药操作失败', error); + Alert.alert( + '操作失败', + error instanceof Error ? error.message : '记录服药时发生错误,请稍后重试', + [{ text: '确定' }] + ); + } finally { + setIsSubmitting(false); + } + }; + const renderStatusBadge = () => { if (medication.status === 'missed') { return ( @@ -104,23 +165,25 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC return ( { - // TODO: 实现服药功能 - console.log('服药功能待实现'); - }} + onPress={handleTakeMedication} + disabled={isSubmitting} > {isLiquidGlassAvailable() ? ( - 立即服用 + + {isSubmitting ? '提交中...' : '立即服用'} + ) : ( - 立即服用 + + {isSubmitting ? '提交中...' : '立即服用'} + )} diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx index 3e01b81..73720f0 100644 --- a/components/ui/IconSymbol.tsx +++ b/components/ui/IconSymbol.tsx @@ -28,6 +28,8 @@ const MAPPING = { 'person.3.fill': 'people', 'message.fill': 'message', 'info.circle': 'info', + 'magnifyingglass': 'search', + 'xmark': 'close', } as IconMapping; /** diff --git a/services/medications.ts b/services/medications.ts new file mode 100644 index 0000000..86e5c4b --- /dev/null +++ b/services/medications.ts @@ -0,0 +1,311 @@ +/** + * 药物管理 API 服务 + */ + +import type { + DailyMedicationStats, + Medication, + MedicationForm, + MedicationRecord, + MedicationStatus, + RepeatPattern, +} from '@/types/medication'; +import { api } from './api'; + +// ==================== DTO 类型定义 ==================== + +/** + * 创建药物 DTO + */ +export interface CreateMedicationDto { + name: string; + photoUrl?: string | null; + form: MedicationForm; + dosageValue: number; + dosageUnit: string; + timesPerDay: number; + medicationTimes: string[]; + startDate: string; + endDate?: string | null; + repeatPattern?: RepeatPattern; + note?: string; +} + +/** + * 更新药物 DTO + */ +export interface UpdateMedicationDto extends Partial { + id: string; + isActive?: boolean; +} + +/** + * 创建服药记录 DTO + */ +export interface CreateMedicationRecordDto { + medicationId: string; + scheduledTime: string; + actualTime?: string; + status: MedicationStatus; + note?: string; +} + +/** + * 更新服药记录 DTO + */ +export interface UpdateMedicationRecordDto { + id: string; + actualTime?: string; + status?: MedicationStatus; + note?: string; +} + +/** + * 获取药物列表参数 + */ +export interface GetMedicationsParams { + isActive?: boolean; + startDate?: string; + endDate?: string; +} + +/** + * 获取服药记录参数 + */ +export interface GetMedicationRecordsParams { + date?: string; + medicationId?: string; + startDate?: string; + endDate?: string; +} + +// ==================== API 函数 ==================== + +/** + * 获取药物列表 + * @param params 查询参数 + * @returns 药物列表 + */ +export const getMedications = async ( + params?: GetMedicationsParams +): Promise => { + const queryParams = new URLSearchParams(); + if (params?.startDate) { + queryParams.append('startDate', params.startDate); + } + if (params?.endDate) { + queryParams.append('endDate', params.endDate); + } + + const query = queryParams.toString(); + const path = query ? `/medications?${query}` : '/medications'; + + const response = await api.get<{ rows: Medication[]; total: number }>(path); + + // 处理不同的响应格式 + if (Array.isArray(response)) { + return response; + } else if (response && typeof response === 'object' && 'rows' in response) { + return response.rows; + } else { + return []; + } +}; + +/** + * 根据 ID 获取单个药物 + * @param id 药物 ID + * @returns 药物详情 + */ +export const getMedicationById = async (id: string): Promise => { + return api.get(`/medications/${id}`); +}; + +/** + * 创建新药物 + * @param dto 创建药物数据 + * @returns 创建的药物 + */ +export const createMedication = async ( + dto: CreateMedicationDto +): Promise => { + return api.post('/medications', dto); +}; + +/** + * 更新药物信息 + * @param dto 更新药物数据 + * @returns 更新后的药物 + */ +export const updateMedication = async ( + dto: UpdateMedicationDto +): Promise => { + const { id, ...data } = dto; + return api.put(`/medications/${id}`, data); +}; + +/** + * 删除药物 + * @param id 药物 ID + */ +export const deleteMedication = async (id: string): Promise => { + return api.delete(`/medications/${id}`); +}; + +/** + * 停用药物 + * @param id 药物 ID + * @returns 更新后的药物 + */ +export const deactivateMedication = async (id: string): Promise => { + return api.post(`/medications/${id}/deactivate`, {}); +}; + +/** + * 激活药物(暂不支持,需要通过更新接口实现) + * @param id 药物 ID + * @returns 更新后的药物 + */ +export const activateMedication = async (id: string): Promise => { + return api.put(`/medications/${id}`, { isActive: true }); +}; + +// ==================== 服药记录相关 ==================== + +/** + * 获取服药记录列表 + * @param params 查询参数 + * @returns 服药记录列表 + */ +export const getMedicationRecords = async ( + params: GetMedicationRecordsParams +): Promise => { + const queryParams = new URLSearchParams(); + if (params.date) { + queryParams.append('date', params.date); + } + if (params.medicationId) { + queryParams.append('medicationId', params.medicationId); + } + if (params.startDate) { + queryParams.append('startDate', params.startDate); + } + if (params.endDate) { + queryParams.append('endDate', params.endDate); + } + + const query = queryParams.toString(); + const path = query ? `/medication-records?${query}` : '/medication-records'; + + return api.get(path); +}; + +/** + * 获取今日服药记录 + * @returns 今日服药记录列表 + */ +export const getTodayMedicationRecords = async (): Promise => { + return api.get('/medication-records/today'); +}; + +/** + * 创建服药记录 + * @param dto 创建服药记录数据 + * @returns 创建的服药记录 + */ +export const createMedicationRecord = async ( + dto: CreateMedicationRecordDto +): Promise => { + return api.post('/medication-records', dto); +}; + +/** + * 更新服药记录 + * @param dto 更新服药记录数据 + * @returns 更新后的服药记录 + */ +export const updateMedicationRecord = async ( + dto: UpdateMedicationRecordDto +): Promise => { + const { id, ...data } = dto; + return api.put(`/medication-records/${id}`, data); +}; + +/** + * 删除服药记录 + * @param id 服药记录 ID + */ +export const deleteMedicationRecord = async (id: string): Promise => { + return api.delete(`/medication-records/${id}`); +}; + +/** + * 标记药物为已服用 + * @param recordId 服药记录 ID + * @param actualTime 实际服药时间(可选,默认为当前时间) + * @returns 更新后的服药记录 + */ +export const takeMedication = async ( + recordId: string, + actualTime?: string +): Promise => { + return api.post(`/medication-records/${recordId}/take`, { + actualTime: actualTime || new Date().toISOString(), + }); +}; + +/** + * 标记药物为已跳过 + * @param recordId 服药记录 ID + * @param note 跳过原因(可选) + * @returns 更新后的服药记录 + */ +export const skipMedication = async ( + recordId: string, + note?: string +): Promise => { + return api.post(`/medication-records/${recordId}/skip`, { + note, + }); +}; + +// ==================== 统计相关 ==================== + +/** + * 获取指定日期的服药统计 + * @param date 日期 'YYYY-MM-DD' + * @returns 每日服药统计 + */ +export const getDailyStats = async ( + date: string +): Promise => { + return api.get(`/medication-stats/daily?date=${date}`); +}; + +/** + * 获取日期范围内的服药统计 + * @param startDate 开始日期 + * @param endDate 结束日期 + * @returns 统计数据列表 + */ +export const getStatsRange = async ( + startDate: string, + endDate: string +): Promise => { + return api.get( + `/medication-stats/range?startDate=${startDate}&endDate=${endDate}` + ); +}; + +/** + * 获取总体统计 + * @returns 总体统计数据 + */ +export const getOverallStats = async (): Promise<{ + totalMedications: number; + totalRecords: number; + completionRate: number; + streak: number; +}> => { + return api.get(`/medication-stats/overall`); +}; \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index a77645a..70ba207 100644 --- a/store/index.ts +++ b/store/index.ts @@ -14,6 +14,7 @@ import fastingReducer, { import foodLibraryReducer from './foodLibrarySlice'; import foodRecognitionReducer from './foodRecognitionSlice'; import healthReducer from './healthSlice'; +import medicationsReducer from './medicationsSlice'; import membershipReducer from './membershipSlice'; import moodReducer from './moodSlice'; import nutritionReducer from './nutritionSlice'; @@ -109,6 +110,7 @@ export const store = configureStore({ workout: workoutReducer, water: waterReducer, fasting: fastingReducer, + medications: medicationsReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware), diff --git a/store/medicationsSlice.ts b/store/medicationsSlice.ts new file mode 100644 index 0000000..a9a436c --- /dev/null +++ b/store/medicationsSlice.ts @@ -0,0 +1,724 @@ +/** + * 药物管理 Redux Slice + */ + +import * as medicationsApi from '@/services/medications'; +import type { + DailyMedicationStats, + Medication, + MedicationRecord, + MedicationStatus, +} from '@/types/medication'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import dayjs from 'dayjs'; +import type { RootState } from './index'; + +// ==================== 状态接口 ==================== + +interface MedicationsState { + // 药物列表 + medications: Medication[]; + // 激活的药物列表(快速访问) + activeMedications: Medication[]; + + // 按日期存储的服药记录 { 'YYYY-MM-DD': MedicationRecord[] } + medicationRecords: Record; + + // 每日统计 { 'YYYY-MM-DD': DailyMedicationStats } + dailyStats: Record; + + // 总体统计 + overallStats: { + totalMedications: number; + totalRecords: number; + completionRate: number; + streak: number; + } | null; + + // 当前选中的日期 + selectedDate: string; + + // 加载状态 + loading: { + medications: boolean; + records: boolean; + stats: boolean; + create: boolean; + update: boolean; + delete: boolean; + takeMedication: boolean; + }; + + // 错误信息 + error: string | null; +} + +// ==================== 初始状态 ==================== + +const initialState: MedicationsState = { + medications: [], + activeMedications: [], + medicationRecords: {}, + dailyStats: {}, + overallStats: null, + selectedDate: dayjs().format('YYYY-MM-DD'), + loading: { + medications: false, + records: false, + stats: false, + create: false, + update: false, + delete: false, + takeMedication: false, + }, + error: null, +}; + +// ==================== 异步 Thunks ==================== + +/** + * 获取药物列表 + */ +export const fetchMedications = createAsyncThunk( + 'medications/fetchMedications', + async (params?: medicationsApi.GetMedicationsParams) => { + return await medicationsApi.getMedications(params); + } +); + +/** + * 获取指定日期的服药记录 + */ +export const fetchMedicationRecords = createAsyncThunk( + 'medications/fetchMedicationRecords', + async (params: { date: string }) => { + const records = await medicationsApi.getMedicationRecords(params); + return { date: params.date, records }; + } +); + +/** + * 获取今日服药记录 + */ +export const fetchTodayMedicationRecords = createAsyncThunk( + 'medications/fetchTodayMedicationRecords', + async () => { + const records = await medicationsApi.getTodayMedicationRecords(); + const today = dayjs().format('YYYY-MM-DD'); + return { date: today, records }; + } +); + +/** + * 获取日期范围内的服药记录 + */ +export const fetchMedicationRecordsByDateRange = createAsyncThunk( + 'medications/fetchMedicationRecordsByDateRange', + async (params: { startDate: string; endDate: string }) => { + const records = await medicationsApi.getMedicationRecords(params); + return { params, records }; + } +); + +/** + * 获取每日统计 + */ +export const fetchDailyStats = createAsyncThunk( + 'medications/fetchDailyStats', + async (date: string) => { + const stats = await medicationsApi.getDailyStats(date); + return { date, stats }; + } +); + +/** + * 获取总体统计 + */ +export const fetchOverallStats = createAsyncThunk( + 'medications/fetchOverallStats', + async () => { + return await medicationsApi.getOverallStats(); + } +); + +/** + * 创建新药物 + */ +export const createMedicationAction = createAsyncThunk( + 'medications/createMedication', + async (dto: medicationsApi.CreateMedicationDto) => { + return await medicationsApi.createMedication(dto); + } +); + +/** + * 更新药物信息 + */ +export const updateMedicationAction = createAsyncThunk( + 'medications/updateMedication', + async (dto: medicationsApi.UpdateMedicationDto) => { + return await medicationsApi.updateMedication(dto); + } +); + +/** + * 删除药物 + */ +export const deleteMedicationAction = createAsyncThunk( + 'medications/deleteMedication', + async (id: string) => { + await medicationsApi.deleteMedication(id); + return id; + } +); + +/** + * 停用药物 + */ +export const deactivateMedicationAction = createAsyncThunk( + 'medications/deactivateMedication', + async (id: string) => { + return await medicationsApi.deactivateMedication(id); + } +); + +/** + * 服用药物 + */ +export const takeMedicationAction = createAsyncThunk( + 'medications/takeMedication', + async (params: { recordId: string; actualTime?: string }) => { + return await medicationsApi.takeMedication(params.recordId, params.actualTime); + } +); + +/** + * 跳过药物 + */ +export const skipMedicationAction = createAsyncThunk( + 'medications/skipMedication', + async (params: { recordId: string; note?: string }) => { + return await medicationsApi.skipMedication(params.recordId, params.note); + } +); + +/** + * 更新服药记录 + */ +export const updateMedicationRecordAction = createAsyncThunk( + 'medications/updateMedicationRecord', + async (dto: medicationsApi.UpdateMedicationRecordDto) => { + return await medicationsApi.updateMedicationRecord(dto); + } +); + +// ==================== Slice ==================== + +const medicationsSlice = createSlice({ + name: 'medications', + initialState, + reducers: { + /** + * 设置选中的日期 + */ + setSelectedDate: (state, action: PayloadAction) => { + state.selectedDate = action.payload; + }, + + /** + * 清除错误信息 + */ + clearError: (state) => { + state.error = null; + }, + + /** + * 清除所有药物数据 + */ + clearMedicationsData: (state) => { + state.medications = []; + state.activeMedications = []; + state.medicationRecords = {}; + state.dailyStats = {}; + state.overallStats = null; + state.error = null; + }, + + /** + * 清除服药记录 + */ + clearMedicationRecords: (state) => { + state.medicationRecords = {}; + state.dailyStats = {}; + }, + + /** + * 本地更新记录状态(用于乐观更新) + */ + updateRecordStatusLocally: ( + state, + action: PayloadAction<{ + recordId: string; + status: MedicationStatus; + date: string; + actualTime?: string; + }> + ) => { + const { recordId, status, date, actualTime } = action.payload; + const records = state.medicationRecords[date]; + if (records) { + const record = records.find((r) => r.id === recordId); + if (record) { + record.status = status; + if (actualTime) { + record.actualTime = actualTime; + } + } + } + + // 更新统计数据 + const stats = state.dailyStats[date]; + if (stats) { + if (status === 'taken') { + stats.taken += 1; + stats.upcoming = Math.max(0, stats.upcoming - 1); + } else if (status === 'missed') { + stats.missed += 1; + stats.upcoming = Math.max(0, stats.upcoming - 1); + } else if (status === 'skipped') { + stats.upcoming = Math.max(0, stats.upcoming - 1); + } + stats.completionRate = stats.totalScheduled > 0 + ? (stats.taken / stats.totalScheduled) * 100 + : 0; + } + }, + + /** + * 添加本地服药记录(用于离线场景) + */ + addLocalMedicationRecord: (state, action: PayloadAction) => { + const record = action.payload; + const date = dayjs(record.scheduledTime).format('YYYY-MM-DD'); + + if (!state.medicationRecords[date]) { + state.medicationRecords[date] = []; + } + + // 检查是否已存在相同ID的记录 + const existingIndex = state.medicationRecords[date].findIndex( + (r) => r.id === record.id + ); + if (existingIndex >= 0) { + state.medicationRecords[date][existingIndex] = record; + } else { + state.medicationRecords[date].push(record); + } + }, + }, + extraReducers: (builder) => { + // ==================== fetchMedications ==================== + builder + .addCase(fetchMedications.pending, (state) => { + state.loading.medications = true; + state.error = null; + }) + .addCase(fetchMedications.fulfilled, (state, action) => { + console.log('action', action); + + state.loading.medications = false; + state.medications = action.payload; + state.activeMedications = action.payload.filter((m) => m.isActive); + }) + .addCase(fetchMedications.rejected, (state, action) => { + state.loading.medications = false; + state.error = action.error.message || '获取药物列表失败'; + }); + + // ==================== fetchMedicationRecords ==================== + builder + .addCase(fetchMedicationRecords.pending, (state) => { + state.loading.records = true; + state.error = null; + }) + .addCase(fetchMedicationRecords.fulfilled, (state, action) => { + state.loading.records = false; + const { date, records } = action.payload; + state.medicationRecords[date] = records; + }) + .addCase(fetchMedicationRecords.rejected, (state, action) => { + state.loading.records = false; + state.error = action.error.message || '获取服药记录失败'; + }); + + // ==================== fetchTodayMedicationRecords ==================== + builder + .addCase(fetchTodayMedicationRecords.pending, (state) => { + state.loading.records = true; + state.error = null; + }) + .addCase(fetchTodayMedicationRecords.fulfilled, (state, action) => { + state.loading.records = false; + const { date, records } = action.payload; + state.medicationRecords[date] = records; + }) + .addCase(fetchTodayMedicationRecords.rejected, (state, action) => { + state.loading.records = false; + state.error = action.error.message || '获取今日服药记录失败'; + }); + + // ==================== fetchMedicationRecordsByDateRange ==================== + builder + .addCase(fetchMedicationRecordsByDateRange.pending, (state) => { + state.loading.records = true; + state.error = null; + }) + .addCase(fetchMedicationRecordsByDateRange.fulfilled, (state, action) => { + state.loading.records = false; + const { records } = action.payload; + + // 按日期分组存储记录 + records.forEach((record) => { + const date = dayjs(record.scheduledTime).format('YYYY-MM-DD'); + if (!state.medicationRecords[date]) { + state.medicationRecords[date] = []; + } + + // 检查是否已存在相同ID的记录 + const existingIndex = state.medicationRecords[date].findIndex( + (r) => r.id === record.id + ); + if (existingIndex >= 0) { + state.medicationRecords[date][existingIndex] = record; + } else { + state.medicationRecords[date].push(record); + } + }); + }) + .addCase(fetchMedicationRecordsByDateRange.rejected, (state, action) => { + state.loading.records = false; + state.error = action.error.message || '获取服药记录失败'; + }); + + // ==================== fetchDailyStats ==================== + builder + .addCase(fetchDailyStats.pending, (state) => { + state.loading.stats = true; + state.error = null; + }) + .addCase(fetchDailyStats.fulfilled, (state, action) => { + state.loading.stats = false; + const { date, stats } = action.payload; + state.dailyStats[date] = stats; + }) + .addCase(fetchDailyStats.rejected, (state, action) => { + state.loading.stats = false; + state.error = action.error.message || '获取统计数据失败'; + }); + + // ==================== fetchOverallStats ==================== + builder + .addCase(fetchOverallStats.pending, (state) => { + state.loading.stats = true; + state.error = null; + }) + .addCase(fetchOverallStats.fulfilled, (state, action) => { + state.loading.stats = false; + state.overallStats = action.payload; + }) + .addCase(fetchOverallStats.rejected, (state, action) => { + state.loading.stats = false; + state.error = action.error.message || '获取总体统计失败'; + }); + + // ==================== createMedication ==================== + builder + .addCase(createMedicationAction.pending, (state) => { + state.loading.create = true; + state.error = null; + }) + .addCase(createMedicationAction.fulfilled, (state, action) => { + state.loading.create = false; + const newMedication = action.payload; + state.medications.push(newMedication); + if (newMedication.isActive) { + state.activeMedications.push(newMedication); + } + }) + .addCase(createMedicationAction.rejected, (state, action) => { + state.loading.create = false; + state.error = action.error.message || '创建药物失败'; + }); + + // ==================== updateMedication ==================== + builder + .addCase(updateMedicationAction.pending, (state) => { + state.loading.update = true; + state.error = null; + }) + .addCase(updateMedicationAction.fulfilled, (state, action) => { + state.loading.update = false; + const updated = action.payload; + const index = state.medications.findIndex((m) => m.id === updated.id); + if (index >= 0) { + // 只有当 isActive 状态改变时才更新 activeMedications + const wasActive = state.medications[index].isActive; + const isActiveNow = updated.isActive; + + // 更新药品信息 + state.medications[index] = updated; + + // 优化:只有当 isActive 状态改变时才重新计算 activeMedications + if (wasActive !== isActiveNow) { + if (isActiveNow) { + // 激活药品:添加到 activeMedications(如果不在其中) + if (!state.activeMedications.some(m => m.id === updated.id)) { + state.activeMedications.push(updated); + } + } else { + // 停用药品:从 activeMedications 中移除 + state.activeMedications = state.activeMedications.filter( + (m) => m.id !== updated.id + ); + } + } else { + // isActive 状态未改变,只需更新 activeMedications 中的对应项 + const activeIndex = state.activeMedications.findIndex((m) => m.id === updated.id); + if (activeIndex >= 0) { + state.activeMedications[activeIndex] = updated; + } + } + } + }) + .addCase(updateMedicationAction.rejected, (state, action) => { + state.loading.update = false; + state.error = action.error.message || '更新药物失败'; + }); + + // ==================== deleteMedication ==================== + builder + .addCase(deleteMedicationAction.pending, (state) => { + state.loading.delete = true; + state.error = null; + }) + .addCase(deleteMedicationAction.fulfilled, (state, action) => { + state.loading.delete = false; + const deletedId = action.payload; + state.medications = state.medications.filter((m) => m.id !== deletedId); + state.activeMedications = state.activeMedications.filter( + (m) => m.id !== deletedId + ); + }) + .addCase(deleteMedicationAction.rejected, (state, action) => { + state.loading.delete = false; + state.error = action.error.message || '删除药物失败'; + }); + + // ==================== deactivateMedication ==================== + builder + .addCase(deactivateMedicationAction.pending, (state) => { + state.loading.update = true; + state.error = null; + }) + .addCase(deactivateMedicationAction.fulfilled, (state, action) => { + state.loading.update = false; + const updated = action.payload; + const index = state.medications.findIndex((m) => m.id === updated.id); + if (index >= 0) { + state.medications[index] = updated; + } + // 从激活列表中移除 + state.activeMedications = state.activeMedications.filter( + (m) => m.id !== updated.id + ); + }) + .addCase(deactivateMedicationAction.rejected, (state, action) => { + state.loading.update = false; + state.error = action.error.message || '停用药物失败'; + }); + + // ==================== takeMedication ==================== + builder + .addCase(takeMedicationAction.pending, (state) => { + state.loading.takeMedication = true; + state.error = null; + }) + .addCase(takeMedicationAction.fulfilled, (state, action) => { + state.loading.takeMedication = false; + const updated = action.payload; + const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD'); + const records = state.medicationRecords[date]; + if (records) { + const index = records.findIndex((r) => r.id === updated.id); + if (index >= 0) { + records[index] = updated; + } + } + + // 更新统计数据 + const stats = state.dailyStats[date]; + if (stats) { + stats.taken += 1; + stats.upcoming = Math.max(0, stats.upcoming - 1); + stats.completionRate = stats.totalScheduled > 0 + ? (stats.taken / stats.totalScheduled) * 100 + : 0; + } + }) + .addCase(takeMedicationAction.rejected, (state, action) => { + state.loading.takeMedication = false; + state.error = action.error.message || '服药操作失败'; + }); + + // ==================== skipMedication ==================== + builder + .addCase(skipMedicationAction.pending, (state) => { + state.loading.takeMedication = true; + state.error = null; + }) + .addCase(skipMedicationAction.fulfilled, (state, action) => { + state.loading.takeMedication = false; + const updated = action.payload; + const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD'); + const records = state.medicationRecords[date]; + if (records) { + const index = records.findIndex((r) => r.id === updated.id); + if (index >= 0) { + records[index] = updated; + } + } + + // 更新统计数据 + const stats = state.dailyStats[date]; + if (stats) { + stats.upcoming = Math.max(0, stats.upcoming - 1); + } + }) + .addCase(skipMedicationAction.rejected, (state, action) => { + state.loading.takeMedication = false; + state.error = action.error.message || '跳过操作失败'; + }); + + // ==================== updateMedicationRecord ==================== + builder + .addCase(updateMedicationRecordAction.pending, (state) => { + state.loading.update = true; + state.error = null; + }) + .addCase(updateMedicationRecordAction.fulfilled, (state, action) => { + state.loading.update = false; + const updated = action.payload; + const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD'); + const records = state.medicationRecords[date]; + if (records) { + const index = records.findIndex((r) => r.id === updated.id); + if (index >= 0) { + records[index] = updated; + } + } + }) + .addCase(updateMedicationRecordAction.rejected, (state, action) => { + state.loading.update = false; + state.error = action.error.message || '更新服药记录失败'; + }); + }, +}); + +// ==================== Actions ==================== + +export const { + setSelectedDate, + clearError, + clearMedicationsData, + clearMedicationRecords, + updateRecordStatusLocally, + addLocalMedicationRecord, +} = medicationsSlice.actions; + +// ==================== Selectors ==================== + +export const selectMedicationsState = (state: RootState) => state.medications; +export const selectMedications = (state: RootState) => state.medications.medications; +export const selectActiveMedications = (state: RootState) => + state.medications.activeMedications; +export const selectSelectedDate = (state: RootState) => state.medications.selectedDate; +export const selectMedicationsLoading = (state: RootState) => state.medications.loading; +export const selectMedicationsError = (state: RootState) => state.medications.error; +export const selectOverallStats = (state: RootState) => state.medications.overallStats; + +/** + * 获取指定日期的服药记录 + */ +export const selectMedicationRecordsByDate = (date: string) => (state: RootState) => { + return state.medications.medicationRecords[date] || []; +}; + +/** + * 获取当前选中日期的服药记录 + */ +export const selectSelectedDateMedicationRecords = (state: RootState) => { + const selectedDate = state.medications.selectedDate; + return state.medications.medicationRecords[selectedDate] || []; +}; + +/** + * 获取指定日期的统计数据 + */ +export const selectDailyStatsByDate = (date: string) => (state: RootState) => { + return state.medications.dailyStats[date]; +}; + +/** + * 获取当前选中日期的统计数据 + */ +export const selectSelectedDateStats = (state: RootState) => { + const selectedDate = state.medications.selectedDate; + return state.medications.dailyStats[selectedDate]; +}; + +/** + * 获取指定日期的展示项列表(用于UI渲染) + * 将药物记录和药物信息合并为展示项 + */ +export const selectMedicationDisplayItemsByDate = (date: string) => (state: RootState) => { + const records = state.medications.medicationRecords[date] || []; + const medications = state.medications.medications; + + // 创建药物ID到药物的映射 + const medicationMap = new Map(); + medications.forEach((med) => medicationMap.set(med.id, med)); + + // 转换为展示项 + return records + .map((record) => { + const medication = record.medication || medicationMap.get(record.medicationId); + if (!medication) return null; + + // 格式化剂量 + const dosage = `${medication.dosageValue} ${medication.dosageUnit}`; + + // 提取并格式化为当地时间(HH:mm格式) + // 服务端返回的是UTC时间,需要转换为用户本地时间显示 + const localTime = dayjs(record.scheduledTime).format('HH:mm'); + const scheduledTime = localTime || '00:00'; + + // 频率描述 + const frequency = medication.repeatPattern === 'daily' ? '每日' : '自定义'; + + return { + id: record.id, + name: medication.name, + dosage, + scheduledTime, + frequency, + status: record.status, + recordId: record.id, + medicationId: medication.id, + } as import('@/types/medication').MedicationDisplayItem; + }) + .filter((item): item is import('@/types/medication').MedicationDisplayItem => item !== null); +}; + +// ==================== Export ==================== + +export default medicationsSlice.reducer; \ No newline at end of file diff --git a/types/medication.ts b/types/medication.ts new file mode 100644 index 0000000..fcea54d --- /dev/null +++ b/types/medication.ts @@ -0,0 +1,93 @@ +/** + * 药物管理类型定义 + */ + +// 药物剂型 +export type MedicationForm = + | 'capsule' // 胶囊 + | 'pill' // 药片 + | 'injection' // 注射 + | 'spray' // 喷雾 + | 'drop' // 滴剂 + | 'syrup' // 糖浆 + | 'other'; // 其他 + +// 服药状态 +export type MedicationStatus = + | 'upcoming' // 待服用 + | 'taken' // 已服用 + | 'missed' // 已错过 + | 'skipped'; // 已跳过 + +// 重复模式 +export type RepeatPattern = + | 'daily' // 每日 + | 'weekly' // 每周 + | 'custom'; // 自定义 + +/** + * 药物基础信息 + */ +export interface Medication { + id: string; + userId: string; // 用户ID(由服务端返回) + name: string; // 药物名称 + photoUrl?: string | null; // 药物照片 + form: MedicationForm; // 剂型 + dosageValue: number; // 剂量值 + dosageUnit: string; // 剂量单位 + timesPerDay: number; // 每日次数 + medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00'] + startDate: string; // 开始日期 ISO + endDate?: string | null; // 结束日期 ISO(可选) + repeatPattern: RepeatPattern; // 重复模式 + note?: string; // 备注 + isActive: boolean; // 是否激活 + deleted: boolean; // 是否已删除(软删除标记) + createdAt: string; // 创建时间 + updatedAt: string; // 更新时间 +} + +/** + * 服药记录 + */ +export interface MedicationRecord { + id: string; + medicationId: string; // 关联的药物ID + userId: string; // 用户ID(由服务端返回) + medication?: Medication; // 关联的药物信息(可选,用于展示) + scheduledTime: string; // 计划服药时间 ISO + actualTime?: string | null; // 实际服药时间 ISO + status: MedicationStatus; // 服药状态 + note?: string; // 记录备注 + deleted: boolean; // 是否已删除(软删除标记) + createdAt: string; + updatedAt: string; +} + +/** + * 每日服药统计 + */ +export interface DailyMedicationStats { + date: string; // 日期 'YYYY-MM-DD' + totalScheduled: number; // 计划总数 + taken: number; // 已服用 + missed: number; // 已错过 + upcoming: number; // 待服用 + completionRate: number; // 完成率 0-100 +} + +/** + * 用于展示的药物记录(组合了药物信息和服药记录) + */ +export interface MedicationDisplayItem { + id: string; + name: string; + dosage: string; // 格式化的剂量字符串,如 "1 粒胶囊" + scheduledTime: string; // 格式化的时间,如 "09:00" + frequency: string; // 频率描述,如 "每日" + status: MedicationStatus; + image?: any; // 图片资源 + recordId?: string; // 服药记录ID(用于更新状态) + medicationId: string; // 药物ID +} \ No newline at end of file diff --git a/utils/medicationHelpers.ts b/utils/medicationHelpers.ts new file mode 100644 index 0000000..034700b --- /dev/null +++ b/utils/medicationHelpers.ts @@ -0,0 +1,91 @@ +/** + * 药物管理辅助函数 + */ + +import type { Medication, MedicationDisplayItem, MedicationRecord } from '@/types/medication'; + +/** + * 将药物和服药记录转换为展示项 + * @param medication 药物信息 + * @param record 服药记录 + * @param imageMap 图片映射(可选) + * @returns 展示项 + */ +export function convertToDisplayItem( + medication: Medication, + record: MedicationRecord, + imageMap?: Record +): MedicationDisplayItem { + // 格式化剂量字符串 + const dosage = `${medication.dosageValue} ${medication.dosageUnit}`; + + // 提取时间(HH:mm格式) + const scheduledTime = record.scheduledTime.split('T')[1]?.substring(0, 5) || '00:00'; + + // 频率描述 + const frequency = medication.repeatPattern === 'daily' ? '每日' : '自定义'; + + return { + id: record.id, + name: medication.name, + dosage, + scheduledTime, + frequency, + status: record.status, + image: imageMap?.[medication.form] || null, + recordId: record.id, + medicationId: medication.id, + }; +} + +/** + * 批量转换药物记录为展示项 + * @param records 服药记录列表 + * @param medications 药物列表 + * @param imageMap 图片映射(可选) + * @returns 展示项列表 + */ +export function convertRecordsToDisplayItems( + records: MedicationRecord[], + medications: Medication[], + imageMap?: Record +): MedicationDisplayItem[] { + const medicationMap = new Map(); + medications.forEach((med) => medicationMap.set(med.id, med)); + + return records + .map((record) => { + const medication = record.medication || medicationMap.get(record.medicationId); + if (!medication) return null; + return convertToDisplayItem(medication, record, imageMap); + }) + .filter((item): item is MedicationDisplayItem => item !== null); +} + +/** + * 格式化剂量字符串 + * @param value 剂量值 + * @param unit 剂量单位 + * @returns 格式化后的字符串 + */ +export function formatDosage(value: number, unit: string): string { + return `${value} ${unit}`; +} + +/** + * 根据剂型获取描述 + * @param form 剂型 + * @returns 描述文本 + */ +export function getMedicationFormLabel(form: string): string { + const formLabels: Record = { + capsule: '胶囊', + pill: '药片', + injection: '注射', + spray: '喷雾', + drop: '滴剂', + syrup: '糖浆', + other: '其他', + }; + return formLabels[form] || '其他'; +} \ No newline at end of file