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

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