feat(medications): 实现完整的用药管理功能

添加了药物管理的核心功能,包括:
- 药物列表展示和状态管理
- 添加新药物的完整流程
- 服药记录的创建和状态更新
- 药物管理界面,支持激活/停用操作
- 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 - 类型定义
This commit is contained in:
richarjiang
2025-11-10 10:02:53 +08:00
parent 3aafc50702
commit 25b8e45af8
11 changed files with 3517 additions and 233 deletions

View File

@@ -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<DateSelectorProps> = ({
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<DateSelectorProps> = ({
// 日历弹窗相关
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(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<DateSelectorProps> = ({
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<DateSelectorProps> = ({
{!isSelectedDateToday() && (
<TouchableOpacity
onPress={handleGoToday}
style={styles.todayButton}
activeOpacity={0.8}
activeOpacity={0.7}
>
<Text style={styles.todayButtonText}></Text>
{isGlassAvailable ? (
<GlassView
style={styles.todayButton}
glassEffectStyle="clear"
tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true}
>
<Text style={styles.todayButtonText}></Text>
</GlassView>
) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text>
</View>
)}
</TouchableOpacity>
)}
{showCalendarIcon && (
<TouchableOpacity
onPress={openDatePicker}
style={styles.calendarIconButton}
activeOpacity={0.7}
activeOpacity={0.6}
>
<Ionicons name="calendar-outline" size={14} color="#666666" />
{isGlassAvailable ? (
<GlassView
style={styles.calendarIconButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</GlassView>
) : (
<View style={[styles.calendarIconButton, styles.calendarIconFallback]}>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</View>
)}
</TouchableOpacity>
)}
</View>
</View>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
style={style}
>
<Animated.View style={{ opacity: fadeAnim }}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
style={style}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
return (
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
<Pressable
onPress={() => !isFutureDate && handleDateSelect(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
style={({ pressed }) => [
!isFutureDate && pressed && styles.dayPillPressed
]}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}>
{d.weekdayZh}
</Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>
{d.dayOfMonth}
</Text>
</TouchableOpacity>
{selected && !isFutureDate ? (
isGlassAvailable ? (
<GlassView
style={styles.dayPill}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Text style={styles.dayLabelSelected}>
{d.weekdayZh}
</Text>
<Text style={styles.dayDateSelected}>
{d.dayOfMonth}
</Text>
</GlassView>
) : (
<View style={[styles.dayPill, styles.dayPillSelectedFallback]}>
<Text style={styles.dayLabelSelected}>
{d.weekdayZh}
</Text>
<Text style={styles.dayDateSelected}>
{d.dayOfMonth}
</Text>
</View>
)
) : (
<View style={[
styles.dayPill,
styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}>
<Text style={[
styles.dayLabel,
isFutureDate && styles.dayLabelDisabled
]}>
{d.weekdayZh}
</Text>
<Text style={[
styles.dayDate,
isFutureDate && styles.dayDateDisabled
]}>
{d.dayOfMonth}
</Text>
</View>
)}
</Pressable>
</View>
);
})}
</ScrollView>
</ScrollView>
</Animated.View>
{/* 日历选择弹窗 */}
<Modal
@@ -298,39 +366,80 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
onRequestClose={closeDatePicker}
>
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
<View style={styles.modalSheet}>
<DateTimePicker
value={pickerDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
} else {
if (event.type === 'set' && date) {
onConfirmDate(date);
{isGlassAvailable ? (
<GlassView
style={styles.modalSheet}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.7)"
isInteractive={false}
>
<DateTimePicker
value={pickerDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
} else {
closeDatePicker();
if (event.type === 'set' && date) {
onConfirmDate(date);
} else {
closeDatePicker();
}
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</Pressable>
<Pressable onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</Pressable>
</View>
)}
</View>
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</GlassView>
) : (
<View style={styles.modalSheet}>
<DateTimePicker
value={pickerDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
} else {
if (event.type === 'set' && date) {
onConfirmDate(date);
} else {
closeDatePicker();
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</View>
)}
</Modal>
</View>
);
@@ -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,
},
});

View File

@@ -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 (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
// TODO: 实现服药功能
console.log('服药功能待实现');
}}
onPress={handleTakeMedication}
disabled={isSubmitting}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonUpcoming]}
glassEffectStyle="clear"
tintColor="rgba(19, 99, 255, 0.3)"
isInteractive={true}
isInteractive={!isSubmitting}
>
<ThemedText style={styles.actionButtonText}></ThemedText>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? '提交中...' : '立即服用'}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
<ThemedText style={styles.actionButtonText}></ThemedText>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? '提交中...' : '立即服用'}
</ThemedText>
</View>
)}
</TouchableOpacity>

View File

@@ -28,6 +28,8 @@ const MAPPING = {
'person.3.fill': 'people',
'message.fill': 'message',
'info.circle': 'info',
'magnifyingglass': 'search',
'xmark': 'close',
} as IconMapping;
/**