Files
digital-pilates/app/medications/manage-medications.tsx
richarjiang 0594831c9f feat(medications): 添加药品详情页面和删除功能
新增药品详情页面,支持查看药品信息、编辑备注、切换提醒状态和删除药品
- 创建动态路由页面 /medications/[medicationId].tsx 展示药品详细信息
- 添加语音输入备注功能,支持 iOS 语音识别
- 实现药品删除确认对话框和删除操作
- 优化药品卡片点击跳转详情页面的交互
- 添加删除操作的加载状态和错误处理
- 改进药品管理页面的开关状态显示和加载指示器
2025-11-10 14:46:13 +08:00

436 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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, onPress }: { medication: Medication; onPress: () => void }) => {
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 (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface }]}
activeOpacity={0.9}
onPress={onPress}
>
<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>
<View style={styles.switchContainer}>
<Switch
value={medication.isActive}
onValueChange={(value) => handleToggleMedication(medication, value)}
disabled={pendingMedicationId === medication.id}
trackColor={{ false: '#D9D9D9', true: colors.primary }}
thumbColor={medication.isActive ? '#fff' : '#fff'}
ios_backgroundColor="#D9D9D9"
/>
{pendingMedicationId === medication.id && (
<ActivityIndicator
size="small"
color={colors.primary}
style={styles.switchLoading}
/>
)}
</View>
</TouchableOpacity>
);
}, (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 handleOpenMedicationDetails = useCallback((medicationId: string) => {
router.push({
pathname: '/medications/[medicationId]',
params: { medicationId },
});
}, []);
const renderMedicationCard = useCallback(
(medication: Medication) => {
return (
<MedicationCard
key={medication.id}
medication={medication}
onPress={() => handleOpenMedicationDetails(medication.id)}
/>
);
},
[handleToggleMedication, pendingMedicationId, colors, handleOpenMedicationDetails]
);
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',
},
switchContainer: {
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
switchLoading: {
position: 'absolute',
marginLeft: 30, // 确保加载指示器显示在开关旁边
},
});