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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
1437
app/medications/add-medication.tsx
Normal file
1437
app/medications/add-medication.tsx
Normal file
File diff suppressed because it is too large
Load Diff
401
app/medications/manage-medications.tsx
Normal file
401
app/medications/manage-medications.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -54,6 +55,9 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
||||
|
||||
// Liquid Glass 可用性检查
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取日期数据
|
||||
const days = getMonthDaysZh(currentMonth);
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||
@@ -79,6 +83,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) => {
|
||||
if (!daysScrollRef.current || scrollWidth === 0) return;
|
||||
@@ -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,25 +241,50 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
{!isSelectedDateToday() && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToday}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.todayButton}
|
||||
activeOpacity={0.8}
|
||||
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}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.calendarIconButton}
|
||||
activeOpacity={0.7}
|
||||
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>
|
||||
)}
|
||||
|
||||
<Animated.View style={{ opacity: fadeAnim }}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -260,35 +299,64 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
|
||||
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
|
||||
]}
|
||||
>
|
||||
{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,
|
||||
selected && styles.dayLabelSelected,
|
||||
isFutureDate && styles.dayLabelDisabled
|
||||
]}>
|
||||
{d.weekdayZh}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.dayDate,
|
||||
selected && styles.dayDateSelected,
|
||||
isFutureDate && styles.dayDateDisabled
|
||||
]}>
|
||||
{d.dayOfMonth}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
||||
{/* 日历选择弹窗 */}
|
||||
<Modal
|
||||
@@ -298,6 +366,46 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
onRequestClose={closeDatePicker}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||
{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 {
|
||||
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>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.modalSheet}>
|
||||
<DateTimePicker
|
||||
value={pickerDate}
|
||||
@@ -320,17 +428,18 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
</Pressable>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
311
services/medications.ts
Normal 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`);
|
||||
};
|
||||
@@ -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
724
store/medicationsSlice.ts
Normal 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
93
types/medication.ts
Normal 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
|
||||
}
|
||||
91
utils/medicationHelpers.ts
Normal file
91
utils/medicationHelpers.ts
Normal 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] || '其他';
|
||||
}
|
||||
Reference in New Issue
Block a user