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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user