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,
},
});