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

@@ -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>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('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<string, Medication[]>;
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() {
</ThemedText>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="pills.fill" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAddMedication}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="plus" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.sectionSpacing}>
@@ -258,18 +246,10 @@ export default function MedicationsScreen() {
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
</ThemedText>
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: colors.primary }]}
>
<IconSymbol name="plus" size={18} color={colors.onPrimary} />
<ThemedText style={[styles.primaryButtonText, { color: colors.onPrimary }]}>
</ThemedText>
</TouchableOpacity>
</View>
) : (
<View style={styles.cardsWrapper}>
{filteredMedications.map((item) => (
{filteredMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
@@ -322,7 +302,25 @@ const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
justifyContent: 'space-between',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
headerAddButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackAddButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
avatar: {
width: 60,
@@ -420,4 +418,14 @@ const styles = StyleSheet.create({
cardsWrapper: {
gap: 16,
},
loadingContainer: {
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
borderRadius: 24,
gap: 16,
},
loadingText: {
fontSize: 14,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<MedicationForm, string> = {
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<FilterType>('all');
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(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<Record<FilterType, number>>(() => {
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 (
<View style={[styles.card, { backgroundColor: colors.surface }]}>
<View style={styles.cardInfo}>
<Image
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
style={styles.cardImage}
contentFit="cover"
/>
<View style={styles.cardTexts}>
<ThemedText style={styles.cardTitle}>{medication.name}</ThemedText>
<ThemedText style={[styles.cardMeta, { color: colors.textSecondary }]}>
{frequencyLabel}
</ThemedText>
<ThemedText style={[styles.cardMeta, { color: colors.textMuted }]}>
{`开始于 ${startDateLabel} 提醒:${reminderLabel}`}
</ThemedText>
</View>
</View>
<Switch
value={medication.isActive}
onValueChange={(value) => handleToggleMedication(medication, value)}
disabled={updateLoading || pendingMedicationId === medication.id}
trackColor={{ false: '#D9D9D9', true: colors.primary }}
thumbColor={medication.isActive ? '#fff' : '#fff'}
ios_backgroundColor="#D9D9D9"
/>
</View>
);
}, (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 <MedicationCard key={medication.id} medication={medication} />;
},
[handleToggleMedication, pendingMedicationId, updateLoading, colors]
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<HeaderBar
title="药品管理"
onBack={() => router.back()}
variant="minimal"
transparent
/>
<View style={{ paddingTop: safeAreaTop }} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 32 },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.pageHeader}>
<View>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={[styles.subtitle, { color: colors.textMuted }]}>
</ThemedText>
</View>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.primary }]}
activeOpacity={0.85}
onPress={() => router.push('/medications/add-medication')}
>
<IconSymbol name="plus" size={20} color={colors.onPrimary} />
</TouchableOpacity>
</View>
<View style={[styles.segmented, { backgroundColor: colors.surface }]}>
{FILTER_CONFIG.map((filter) => {
const isActive = filter.key === activeFilter;
return (
<TouchableOpacity
key={filter.key}
style={[
styles.segmentButton,
isActive && { backgroundColor: colors.primary },
]}
activeOpacity={0.85}
onPress={() => setActiveFilter(filter.key)}
>
<ThemedText
style={[
styles.segmentLabel,
{ color: isActive ? colors.onPrimary : colors.textSecondary },
]}
>
{filter.label}
</ThemedText>
<View
style={[
styles.segmentBadge,
{
backgroundColor: isActive
? colors.onPrimary
: `${colors.primary}15`,
},
]}
>
<ThemedText
style={[
styles.segmentBadgeLabel,
{ color: isActive ? colors.primary : colors.textSecondary },
]}
>
{counts[filter.key] ?? 0}
</ThemedText>
</View>
</TouchableOpacity>
);
})}
</View>
{listLoading ? (
<View style={[styles.loading, { backgroundColor: colors.surface }]}>
<ActivityIndicator color={colors.primary} />
<ThemedText style={styles.loadingText}>...</ThemedText>
</View>
) : filteredMedications.length === 0 ? (
<View style={[styles.empty, { backgroundColor: colors.surface }]}>
<Image source={DEFAULT_IMAGE} style={styles.emptyImage} contentFit="contain" />
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textSecondary }]}>
</ThemedText>
</View>
) : (
<View style={styles.list}>{filteredMedications.map(renderMedicationCard)}</View>
)}
</ScrollView>
</View>
);
}
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',
},
});

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;
/**

311
services/medications.ts Normal file
View File

@@ -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<CreateMedicationDto> {
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<Medication[]> => {
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<Medication> => {
return api.get<Medication>(`/medications/${id}`);
};
/**
* 创建新药物
* @param dto 创建药物数据
* @returns 创建的药物
*/
export const createMedication = async (
dto: CreateMedicationDto
): Promise<Medication> => {
return api.post<Medication>('/medications', dto);
};
/**
* 更新药物信息
* @param dto 更新药物数据
* @returns 更新后的药物
*/
export const updateMedication = async (
dto: UpdateMedicationDto
): Promise<Medication> => {
const { id, ...data } = dto;
return api.put<Medication>(`/medications/${id}`, data);
};
/**
* 删除药物
* @param id 药物 ID
*/
export const deleteMedication = async (id: string): Promise<void> => {
return api.delete<void>(`/medications/${id}`);
};
/**
* 停用药物
* @param id 药物 ID
* @returns 更新后的药物
*/
export const deactivateMedication = async (id: string): Promise<Medication> => {
return api.post<Medication>(`/medications/${id}/deactivate`, {});
};
/**
* 激活药物(暂不支持,需要通过更新接口实现)
* @param id 药物 ID
* @returns 更新后的药物
*/
export const activateMedication = async (id: string): Promise<Medication> => {
return api.put<Medication>(`/medications/${id}`, { isActive: true });
};
// ==================== 服药记录相关 ====================
/**
* 获取服药记录列表
* @param params 查询参数
* @returns 服药记录列表
*/
export const getMedicationRecords = async (
params: GetMedicationRecordsParams
): Promise<MedicationRecord[]> => {
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<MedicationRecord[]>(path);
};
/**
* 获取今日服药记录
* @returns 今日服药记录列表
*/
export const getTodayMedicationRecords = async (): Promise<MedicationRecord[]> => {
return api.get<MedicationRecord[]>('/medication-records/today');
};
/**
* 创建服药记录
* @param dto 创建服药记录数据
* @returns 创建的服药记录
*/
export const createMedicationRecord = async (
dto: CreateMedicationRecordDto
): Promise<MedicationRecord> => {
return api.post<MedicationRecord>('/medication-records', dto);
};
/**
* 更新服药记录
* @param dto 更新服药记录数据
* @returns 更新后的服药记录
*/
export const updateMedicationRecord = async (
dto: UpdateMedicationRecordDto
): Promise<MedicationRecord> => {
const { id, ...data } = dto;
return api.put<MedicationRecord>(`/medication-records/${id}`, data);
};
/**
* 删除服药记录
* @param id 服药记录 ID
*/
export const deleteMedicationRecord = async (id: string): Promise<void> => {
return api.delete<void>(`/medication-records/${id}`);
};
/**
* 标记药物为已服用
* @param recordId 服药记录 ID
* @param actualTime 实际服药时间(可选,默认为当前时间)
* @returns 更新后的服药记录
*/
export const takeMedication = async (
recordId: string,
actualTime?: string
): Promise<MedicationRecord> => {
return api.post<MedicationRecord>(`/medication-records/${recordId}/take`, {
actualTime: actualTime || new Date().toISOString(),
});
};
/**
* 标记药物为已跳过
* @param recordId 服药记录 ID
* @param note 跳过原因(可选)
* @returns 更新后的服药记录
*/
export const skipMedication = async (
recordId: string,
note?: string
): Promise<MedicationRecord> => {
return api.post<MedicationRecord>(`/medication-records/${recordId}/skip`, {
note,
});
};
// ==================== 统计相关 ====================
/**
* 获取指定日期的服药统计
* @param date 日期 'YYYY-MM-DD'
* @returns 每日服药统计
*/
export const getDailyStats = async (
date: string
): Promise<DailyMedicationStats> => {
return api.get<DailyMedicationStats>(`/medication-stats/daily?date=${date}`);
};
/**
* 获取日期范围内的服药统计
* @param startDate 开始日期
* @param endDate 结束日期
* @returns 统计数据列表
*/
export const getStatsRange = async (
startDate: string,
endDate: string
): Promise<DailyMedicationStats[]> => {
return api.get<DailyMedicationStats[]>(
`/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`);
};

View File

@@ -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),

724
store/medicationsSlice.ts Normal file
View File

@@ -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<string, MedicationRecord[]>;
// 每日统计 { 'YYYY-MM-DD': DailyMedicationStats }
dailyStats: Record<string, DailyMedicationStats>;
// 总体统计
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<string>) => {
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<MedicationRecord>) => {
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<string, Medication>();
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;

93
types/medication.ts Normal file
View File

@@ -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
}

View File

@@ -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<string, any>
): 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<string, any>
): MedicationDisplayItem[] {
const medicationMap = new Map<string, Medication>();
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<string, string> = {
capsule: '胶囊',
pill: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
syrup: '糖浆',
other: '其他',
};
return formLabels[form] || '其他';
}