feat(medications): 添加用药管理功能
- 新增用药标签页,包含完整的用药记录界面 - 实现用药卡片组件,支持状态显示(已服用/未服用/已错过) - 增强日期选择器,添加"回到今天"快捷功能 - 添加用药相关的图标支持(pills.fill, plus) - 集成用药路由配置,支持标签页导航 该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
This commit is contained in:
@@ -58,6 +58,17 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const days = getMonthDaysZh(currentMonth);
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||
|
||||
// 判断当前选中的日期是否是今天
|
||||
const isSelectedDateToday = () => {
|
||||
const today = dayjs();
|
||||
const selectedDate = days[selectedIndex]?.date;
|
||||
|
||||
if (!selectedDate) return false;
|
||||
|
||||
// 检查是否是同一天且在同一个月
|
||||
return selectedDate.isSame(today, 'day') && currentMonth.isSame(today, 'month');
|
||||
};
|
||||
|
||||
// 滚动相关
|
||||
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
@@ -191,20 +202,47 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToday = () => {
|
||||
const today = dayjs();
|
||||
setCurrentMonth(today);
|
||||
const todayDays = getMonthDaysZh(today);
|
||||
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
|
||||
|
||||
if (newSelectedIndex !== -1) {
|
||||
if (externalSelectedIndex === undefined) {
|
||||
setInternalSelectedIndex(newSelectedIndex);
|
||||
}
|
||||
const todayDate = today.toDate();
|
||||
setPickerDate(todayDate);
|
||||
onDateSelect?.(newSelectedIndex, todayDate);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{showMonthTitle && (
|
||||
<View style={styles.monthTitleContainer}>
|
||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||
{showCalendarIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={openDatePicker}
|
||||
style={styles.calendarIconButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View style={styles.monthActions}>
|
||||
{!isSelectedDateToday() && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToday}
|
||||
style={styles.todayButton}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{showCalendarIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={openDatePicker}
|
||||
style={styles.calendarIconButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -305,9 +343,13 @@ const styles = StyleSheet.create({
|
||||
monthTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
monthActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
@@ -318,6 +360,18 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 6,
|
||||
marginLeft: 4
|
||||
},
|
||||
todayButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EEF2FF',
|
||||
marginRight: 8,
|
||||
},
|
||||
todayButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#4C1D95',
|
||||
},
|
||||
daysContainer: {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
301
components/medication/MedicationCard.tsx
Normal file
301
components/medication/MedicationCard.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
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;
|
||||
};
|
||||
|
||||
export type MedicationCardProps = {
|
||||
medication: Medication;
|
||||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||
selectedDate: Dayjs;
|
||||
};
|
||||
|
||||
export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) {
|
||||
const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`);
|
||||
const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute');
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (medication.status === 'missed') {
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipMissed]}>
|
||||
<ThemedText style={styles.statusChipText}>已错过</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (medication.status === 'upcoming') {
|
||||
if (timeDiffMinutes <= 0) {
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>到服药时间</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const hours = Math.floor(timeDiffMinutes / 60);
|
||||
const minutes = timeDiffMinutes % 60;
|
||||
const formatted =
|
||||
hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`;
|
||||
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>剩余 {formatted}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
if (medication.status === 'taken') {
|
||||
return (
|
||||
<View style={[styles.actionButton, styles.actionButtonTaken]}>
|
||||
<Ionicons name="checkmark-circle" size={18} color="#fff" />
|
||||
<ThemedText style={styles.actionButtonText}>已服用</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (medication.status === 'missed') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
disabled={true}
|
||||
onPress={() => {
|
||||
// 已错过的药物不能服用
|
||||
console.log('已错过的药物不能服用');
|
||||
}}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={[styles.actionButton, styles.actionButtonMissed]}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(156, 163, 175, 0.3)"
|
||||
isInteractive={false}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonTextMissed}>已错过</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonMissed, styles.fallbackActionButtonMissed]}>
|
||||
<ThemedText style={styles.actionButtonTextMissed}>已错过</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: 实现服药功能
|
||||
console.log('服药功能待实现');
|
||||
}}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(19, 99, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const statusChip = renderStatusBadge();
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { shadowColor: colors.text }]}>
|
||||
<View style={[styles.cardSurface, { backgroundColor: colors.surface }]}>
|
||||
{statusChip ? <View style={styles.statusChipWrapper}>{statusChip}</View> : null}
|
||||
<View style={styles.cardBody}>
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.thumbnailWrapper}>
|
||||
<View style={styles.thumbnailSurface}>
|
||||
<Image source={medication.image} style={styles.thumbnailImage} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.infoSection}>
|
||||
<ThemedText style={[styles.cardTitle, { color: colors.text }]}>
|
||||
{medication.name}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.cardDosage, { color: colors.textSecondary }]}>
|
||||
{medication.dosage}
|
||||
</ThemedText>
|
||||
<View style={styles.scheduleRow}>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={14}
|
||||
color={colors.textSecondary}
|
||||
style={styles.scheduleIcon}
|
||||
/>
|
||||
<ThemedText style={[styles.cardSchedule, { color: colors.textSecondary }]}>
|
||||
{medication.scheduledTime} | {medication.frequency}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.actionContainer}>{renderAction()}</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 26,
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 24,
|
||||
elevation: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
cardSurface: {
|
||||
borderRadius: 26,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardBody: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 28,
|
||||
},
|
||||
cardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
thumbnailWrapper: {
|
||||
width: 126,
|
||||
height: 110,
|
||||
},
|
||||
thumbnailSurface: {
|
||||
flex: 1,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#F1F4FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnailImage: {
|
||||
width: '80%',
|
||||
height: '80%',
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
infoSection: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cardDosage: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
scheduleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
cardSchedule: {
|
||||
fontSize: 12,
|
||||
},
|
||||
scheduleIcon: {
|
||||
marginTop: -1,
|
||||
},
|
||||
actionContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
actionButton: {
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
justifyContent: 'center',
|
||||
height: 38,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
actionButtonUpcoming: {
|
||||
backgroundColor: '#1363FF',
|
||||
},
|
||||
actionButtonTaken: {
|
||||
backgroundColor: '#1FBF4B',
|
||||
},
|
||||
actionButtonMissed: {
|
||||
backgroundColor: '#9CA3AF',
|
||||
},
|
||||
fallbackActionButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(19, 99, 255, 0.3)',
|
||||
backgroundColor: 'rgba(19, 99, 255, 0.9)',
|
||||
},
|
||||
fallbackActionButtonMissed: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(156, 163, 175, 0.3)',
|
||||
backgroundColor: 'rgba(156, 163, 175, 0.9)',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
actionButtonTextMissed: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
statusChipWrapper: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
statusChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
height: 28,
|
||||
borderBottomLeftRadius: 20,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
backgroundColor: '#1363FF',
|
||||
},
|
||||
statusChipUpcoming: {
|
||||
backgroundColor: '#1363FF',
|
||||
},
|
||||
statusChipMissed: {
|
||||
backgroundColor: '#FF3B30',
|
||||
},
|
||||
statusChipText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@@ -23,6 +23,8 @@ const MAPPING = {
|
||||
'trophy.fill': 'emoji-events',
|
||||
'timer': 'timer',
|
||||
'person.fill': 'person',
|
||||
'plus': 'add',
|
||||
'pills.fill': 'medication',
|
||||
'person.3.fill': 'people',
|
||||
'message.fill': 'message',
|
||||
'info.circle': 'info',
|
||||
|
||||
Reference in New Issue
Block a user