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 { 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 { ThemedText } from '@/components/ThemedText';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate, selectMedicationsLoading } from '@/store/medicationsSlice';
|
||||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import { router } from 'expo-router';
|
||||||
import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
@@ -20,133 +29,71 @@ type MedicationFilter = 'all' | 'taken' | 'missed';
|
|||||||
|
|
||||||
type ThemeColors = (typeof Colors)[keyof typeof Colors];
|
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() {
|
export default function MedicationsScreen() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colors: ThemeColors = Colors[theme];
|
const colors: ThemeColors = Colors[theme];
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||||
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
||||||
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
||||||
|
|
||||||
const scheduledMedications = useMemo(() => {
|
// 从 Redux 获取数据
|
||||||
const today = dayjs();
|
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||||
const todayKey = today.format('YYYY-MM-DD');
|
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
|
||||||
const yesterdayKey = today.subtract(1, 'day').format('YYYY-MM-DD');
|
const loading = useAppSelector(selectMedicationsLoading);
|
||||||
const twoDaysAgoKey = today.subtract(2, 'day').format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
return {
|
const handleOpenAddMedication = useCallback(() => {
|
||||||
[todayKey]: [
|
router.push('/medications/add-medication');
|
||||||
{
|
|
||||||
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 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(() => {
|
useEffect(() => {
|
||||||
setActiveFilter('all');
|
setActiveFilter('all');
|
||||||
}, [selectedDate]);
|
}, [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(() => {
|
const filteredMedications = useMemo(() => {
|
||||||
if (activeFilter === 'all') {
|
if (activeFilter === 'all') {
|
||||||
return medicationsForDay;
|
return medicationsWithImages;
|
||||||
}
|
}
|
||||||
return medicationsForDay.filter((item) => item.status === activeFilter);
|
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
||||||
}, [activeFilter, medicationsForDay]);
|
}, [activeFilter, medicationsWithImages]);
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const taken = medicationsForDay.filter((item) => item.status === 'taken').length;
|
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
||||||
const missed = medicationsForDay.filter((item) => item.status === 'missed').length;
|
const missed = medicationsWithImages.filter((item: any) => item.status === 'missed').length;
|
||||||
return {
|
return {
|
||||||
all: medicationsForDay.length,
|
all: medicationsWithImages.length,
|
||||||
taken,
|
taken,
|
||||||
missed,
|
missed,
|
||||||
};
|
};
|
||||||
}, [medicationsForDay]);
|
}, [medicationsWithImages]);
|
||||||
|
|
||||||
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
|
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
|
||||||
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
|
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
|
||||||
@@ -183,6 +130,47 @@ export default function MedicationsScreen() {
|
|||||||
欢迎来到用药助手!
|
欢迎来到用药助手!
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View style={styles.sectionSpacing}>
|
<View style={styles.sectionSpacing}>
|
||||||
@@ -258,18 +246,10 @@ export default function MedicationsScreen() {
|
|||||||
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||||||
还未添加任何用药计划,快来补充吧。
|
还未添加任何用药计划,快来补充吧。
|
||||||
</ThemedText>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.cardsWrapper}>
|
<View style={styles.cardsWrapper}>
|
||||||
{filteredMedications.map((item) => (
|
{filteredMedications.map((item: any) => (
|
||||||
<MedicationCard
|
<MedicationCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
medication={item}
|
medication={item}
|
||||||
@@ -322,7 +302,25 @@ const styles = StyleSheet.create({
|
|||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
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: {
|
avatar: {
|
||||||
width: 60,
|
width: 60,
|
||||||
@@ -420,4 +418,14 @@ const styles = StyleSheet.create({
|
|||||||
cardsWrapper: {
|
cardsWrapper: {
|
||||||
gap: 16,
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Animated, Modal,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
export interface DateSelectorProps {
|
export interface DateSelectorProps {
|
||||||
@@ -53,6 +54,9 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||||
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
||||||
|
|
||||||
|
// Liquid Glass 可用性检查
|
||||||
|
const isGlassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
// 获取日期数据
|
// 获取日期数据
|
||||||
const days = getMonthDaysZh(currentMonth);
|
const days = getMonthDaysZh(currentMonth);
|
||||||
@@ -78,6 +82,9 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
// 日历弹窗相关
|
// 日历弹窗相关
|
||||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
// 动画值
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
// 滚动到指定索引
|
// 滚动到指定索引
|
||||||
const scrollToIndex = (index: number, animated = true) => {
|
const scrollToIndex = (index: number, animated = true) => {
|
||||||
@@ -113,7 +120,14 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
if (scrollWidth > 0 && autoScrollToSelected) {
|
if (scrollWidth > 0 && autoScrollToSelected) {
|
||||||
scrollToIndex(selectedIndex, true);
|
scrollToIndex(selectedIndex, true);
|
||||||
}
|
}
|
||||||
}, [scrollWidth, selectedIndex, autoScrollToSelected]);
|
|
||||||
|
// 淡入动画
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [scrollWidth, selectedIndex, autoScrollToSelected, fadeAnim]);
|
||||||
|
|
||||||
// 当选中索引变化时,滚动到对应位置
|
// 当选中索引变化时,滚动到对应位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -227,68 +241,122 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
{!isSelectedDateToday() && (
|
{!isSelectedDateToday() && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleGoToday}
|
onPress={handleGoToday}
|
||||||
style={styles.todayButton}
|
activeOpacity={0.7}
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
{isGlassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.todayButton}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(124, 58, 237, 0.08)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.todayButton, styles.todayButtonFallback]}>
|
||||||
|
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{showCalendarIcon && (
|
{showCalendarIcon && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={openDatePicker}
|
onPress={openDatePicker}
|
||||||
style={styles.calendarIconButton}
|
activeOpacity={0.6}
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
{isGlassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.calendarIconButton}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.2)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.calendarIconButton, styles.calendarIconFallback]}>
|
||||||
|
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<Animated.View style={{ opacity: fadeAnim }}>
|
||||||
horizontal
|
<ScrollView
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
contentContainerStyle={styles.daysContainer}
|
showsHorizontalScrollIndicator={false}
|
||||||
ref={daysScrollRef}
|
contentContainerStyle={styles.daysContainer}
|
||||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
ref={daysScrollRef}
|
||||||
style={style}
|
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||||
>
|
style={style}
|
||||||
|
>
|
||||||
{days.map((d, i) => {
|
{days.map((d, i) => {
|
||||||
const selected = i === selectedIndex;
|
const selected = i === selectedIndex;
|
||||||
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
|
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
|
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
style={[
|
|
||||||
styles.dayPill,
|
|
||||||
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
|
||||||
isFutureDate && styles.dayPillDisabled
|
|
||||||
]}
|
|
||||||
onPress={() => !isFutureDate && handleDateSelect(i)}
|
onPress={() => !isFutureDate && handleDateSelect(i)}
|
||||||
activeOpacity={isFutureDate ? 1 : 0.8}
|
|
||||||
disabled={isFutureDate}
|
disabled={isFutureDate}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
!isFutureDate && pressed && styles.dayPillPressed
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[
|
{selected && !isFutureDate ? (
|
||||||
styles.dayLabel,
|
isGlassAvailable ? (
|
||||||
selected && styles.dayLabelSelected,
|
<GlassView
|
||||||
isFutureDate && styles.dayLabelDisabled
|
style={styles.dayPill}
|
||||||
]}>
|
glassEffectStyle="regular"
|
||||||
{d.weekdayZh}
|
tintColor="rgba(255, 255, 255, 0.3)"
|
||||||
</Text>
|
isInteractive={true}
|
||||||
<Text style={[
|
>
|
||||||
styles.dayDate,
|
<Text style={styles.dayLabelSelected}>
|
||||||
selected && styles.dayDateSelected,
|
{d.weekdayZh}
|
||||||
isFutureDate && styles.dayDateDisabled
|
</Text>
|
||||||
]}>
|
<Text style={styles.dayDateSelected}>
|
||||||
{d.dayOfMonth}
|
{d.dayOfMonth}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.dayPill, styles.dayPillSelectedFallback]}>
|
||||||
|
<Text style={styles.dayLabelSelected}>
|
||||||
|
{d.weekdayZh}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.dayDateSelected}>
|
||||||
|
{d.dayOfMonth}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={[
|
||||||
|
styles.dayPill,
|
||||||
|
styles.dayPillNormal,
|
||||||
|
isFutureDate && styles.dayPillDisabled
|
||||||
|
]}>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayLabel,
|
||||||
|
isFutureDate && styles.dayLabelDisabled
|
||||||
|
]}>
|
||||||
|
{d.weekdayZh}
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayDate,
|
||||||
|
isFutureDate && styles.dayDateDisabled
|
||||||
|
]}>
|
||||||
|
{d.dayOfMonth}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
{/* 日历选择弹窗 */}
|
{/* 日历选择弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -298,39 +366,80 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
onRequestClose={closeDatePicker}
|
onRequestClose={closeDatePicker}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
<View style={styles.modalSheet}>
|
{isGlassAvailable ? (
|
||||||
<DateTimePicker
|
<GlassView
|
||||||
value={pickerDate}
|
style={styles.modalSheet}
|
||||||
mode="date"
|
glassEffectStyle="regular"
|
||||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
tintColor="rgba(255, 255, 255, 0.7)"
|
||||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
isInteractive={false}
|
||||||
maximumDate={disableFutureDates ? new Date() : undefined}
|
>
|
||||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
<DateTimePicker
|
||||||
onChange={(event, date) => {
|
value={pickerDate}
|
||||||
if (Platform.OS === 'ios') {
|
mode="date"
|
||||||
if (date) setPickerDate(date);
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
} else {
|
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||||
if (event.type === 'set' && date) {
|
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||||
onConfirmDate(date);
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
} else {
|
} else {
|
||||||
closeDatePicker();
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
{Platform.OS === 'ios' && (
|
||||||
{Platform.OS === 'ios' && (
|
<View style={styles.modalActions}>
|
||||||
<View style={styles.modalActions}>
|
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
<Text style={styles.modalBtnText}>取消</Text>
|
</TouchableOpacity>
|
||||||
</Pressable>
|
<TouchableOpacity onPress={() => {
|
||||||
<Pressable onPress={() => {
|
onConfirmDate(pickerDate);
|
||||||
onConfirmDate(pickerDate);
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
</TouchableOpacity>
|
||||||
</Pressable>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</GlassView>
|
||||||
</View>
|
) : (
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||||
|
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => {
|
||||||
|
onConfirmDate(pickerDate);
|
||||||
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -351,26 +460,39 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
monthTitle: {
|
monthTitle: {
|
||||||
fontSize: 20,
|
fontSize: 22,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#192126',
|
color: '#1a1a1a',
|
||||||
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
calendarIconButton: {
|
calendarIconButton: {
|
||||||
padding: 4,
|
padding: 4,
|
||||||
borderRadius: 6,
|
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: {
|
todayButton: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#EEF2FF',
|
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
todayButtonFallback: {
|
||||||
|
backgroundColor: '#EEF2FF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(124, 58, 237, 0.2)',
|
||||||
},
|
},
|
||||||
todayButtonText: {
|
todayButtonText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
color: '#4C1D95',
|
color: '#7c3aed',
|
||||||
|
letterSpacing: 0.2,
|
||||||
},
|
},
|
||||||
daysContainer: {
|
daysContainer: {
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
@@ -386,17 +508,24 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
dayPillNormal: {
|
dayPillNormal: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
dayPillSelected: {
|
dayPillPressed: {
|
||||||
|
opacity: 0.8,
|
||||||
|
transform: [{ scale: 0.96 }],
|
||||||
|
},
|
||||||
|
dayPillSelectedFallback: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||||
},
|
},
|
||||||
dayPillDisabled: {
|
dayPillDisabled: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -405,39 +534,47 @@ const styles = StyleSheet.create({
|
|||||||
dayLabel: {
|
dayLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: 'gray',
|
color: '#8e8e93',
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
|
letterSpacing: 0.1,
|
||||||
},
|
},
|
||||||
dayLabelSelected: {
|
dayLabelSelected: {
|
||||||
color: '#192126',
|
color: '#1a1a1a',
|
||||||
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
dayLabelDisabled: {
|
dayLabelDisabled: {
|
||||||
color: 'gray',
|
color: '#c7c7cc',
|
||||||
},
|
},
|
||||||
dayDate: {
|
dayDate: {
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
color: 'gray',
|
color: '#8e8e93',
|
||||||
|
letterSpacing: -0.2,
|
||||||
},
|
},
|
||||||
dayDateSelected: {
|
dayDateSelected: {
|
||||||
color: '#192126',
|
color: '#1a1a1a',
|
||||||
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
dayDateDisabled: {
|
dayDateDisabled: {
|
||||||
color: 'gray',
|
color: '#c7c7cc',
|
||||||
},
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
},
|
},
|
||||||
modalSheet: {
|
modalSheet: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
padding: 16,
|
padding: 20,
|
||||||
backgroundColor: '#FFFFFF',
|
borderTopLeftRadius: 20,
|
||||||
borderTopLeftRadius: 16,
|
borderTopRightRadius: 20,
|
||||||
borderTopRightRadius: 16,
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 10,
|
||||||
},
|
},
|
||||||
modalActions: {
|
modalActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -446,20 +583,35 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
modalBtn: {
|
modalBtn: {
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderRadius: 10,
|
borderRadius: 12,
|
||||||
backgroundColor: '#F1F5F9',
|
backgroundColor: '#f8fafc',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
modalBtnPrimary: {
|
modalBtnPrimary: {
|
||||||
backgroundColor: '#7a5af8',
|
backgroundColor: '#7c3aed',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#7c3aed',
|
||||||
|
shadowColor: '#7c3aed',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
},
|
},
|
||||||
modalBtnText: {
|
modalBtnText: {
|
||||||
color: '#334155',
|
color: '#475569',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.1,
|
||||||
},
|
},
|
||||||
modalBtnTextPrimary: {
|
modalBtnTextPrimary: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,94 @@
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
export type MedicationStatus = 'upcoming' | 'taken' | 'missed';
|
|
||||||
|
|
||||||
export type Medication = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
dosage: string;
|
|
||||||
scheduledTime: string;
|
|
||||||
frequency: string;
|
|
||||||
status: MedicationStatus;
|
|
||||||
image: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MedicationCardProps = {
|
export type MedicationCardProps = {
|
||||||
medication: Medication;
|
medication: MedicationDisplayItem;
|
||||||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||||
selectedDate: Dayjs;
|
selectedDate: Dayjs;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) {
|
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 scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`);
|
||||||
const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute');
|
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 = () => {
|
const renderStatusBadge = () => {
|
||||||
if (medication.status === 'missed') {
|
if (medication.status === 'missed') {
|
||||||
return (
|
return (
|
||||||
@@ -104,23 +165,25 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={() => {
|
onPress={handleTakeMedication}
|
||||||
// TODO: 实现服药功能
|
disabled={isSubmitting}
|
||||||
console.log('服药功能待实现');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isLiquidGlassAvailable() ? (
|
{isLiquidGlassAvailable() ? (
|
||||||
<GlassView
|
<GlassView
|
||||||
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||||||
glassEffectStyle="clear"
|
glassEffectStyle="clear"
|
||||||
tintColor="rgba(19, 99, 255, 0.3)"
|
tintColor="rgba(19, 99, 255, 0.3)"
|
||||||
isInteractive={true}
|
isInteractive={!isSubmitting}
|
||||||
>
|
>
|
||||||
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
<ThemedText style={styles.actionButtonText}>
|
||||||
|
{isSubmitting ? '提交中...' : '立即服用'}
|
||||||
|
</ThemedText>
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||||
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
<ThemedText style={styles.actionButtonText}>
|
||||||
|
{isSubmitting ? '提交中...' : '立即服用'}
|
||||||
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const MAPPING = {
|
|||||||
'person.3.fill': 'people',
|
'person.3.fill': 'people',
|
||||||
'message.fill': 'message',
|
'message.fill': 'message',
|
||||||
'info.circle': 'info',
|
'info.circle': 'info',
|
||||||
|
'magnifyingglass': 'search',
|
||||||
|
'xmark': 'close',
|
||||||
} as IconMapping;
|
} 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 foodLibraryReducer from './foodLibrarySlice';
|
||||||
import foodRecognitionReducer from './foodRecognitionSlice';
|
import foodRecognitionReducer from './foodRecognitionSlice';
|
||||||
import healthReducer from './healthSlice';
|
import healthReducer from './healthSlice';
|
||||||
|
import medicationsReducer from './medicationsSlice';
|
||||||
import membershipReducer from './membershipSlice';
|
import membershipReducer from './membershipSlice';
|
||||||
import moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import nutritionReducer from './nutritionSlice';
|
import nutritionReducer from './nutritionSlice';
|
||||||
@@ -109,6 +110,7 @@ export const store = configureStore({
|
|||||||
workout: workoutReducer,
|
workout: workoutReducer,
|
||||||
water: waterReducer,
|
water: waterReducer,
|
||||||
fasting: fastingReducer,
|
fasting: fastingReducer,
|
||||||
|
medications: medicationsReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
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