feat(medications): 添加药品详情页面和删除功能

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

View File

@@ -52,6 +52,13 @@ export default function MedicationsScreen() {
router.push('/medications/manage-medications'); router.push('/medications/manage-medications');
}, []); }, []);
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
router.push({
pathname: '/medications/[medicationId]',
params: { medicationId },
});
}, []);
// 加载药物和记录数据 // 加载药物和记录数据
useEffect(() => { useEffect(() => {
dispatch(fetchMedications()); dispatch(fetchMedications());
@@ -255,6 +262,7 @@ export default function MedicationsScreen() {
medication={item} medication={item}
colors={colors} colors={colors}
selectedDate={selectedDate} selectedDate={selectedDate}
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
/> />
))} ))}
</View> </View>

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,6 @@ export default function ManageMedicationsScreen() {
const [activeFilter, setActiveFilter] = useState<FilterType>('all'); const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null); const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null);
const updateLoading = loading.update;
const listLoading = loading.medications && medications.length === 0; const listLoading = loading.medications && medications.length === 0;
useFocusEffect( useFocusEffect(
@@ -116,7 +115,7 @@ export default function ManageMedicationsScreen() {
); );
// 创建独立的药品卡片组件,使用 React.memo 优化渲染 // 创建独立的药品卡片组件,使用 React.memo 优化渲染
const MedicationCard = React.memo(({ medication }: { medication: Medication }) => { const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => {
const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim(); const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim();
const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`; const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`;
const startDateLabel = dayjs(medication.startDate).isValid() const startDateLabel = dayjs(medication.startDate).isValid()
@@ -127,7 +126,11 @@ export default function ManageMedicationsScreen() {
: `${medication.timesPerDay} 次/日`; : `${medication.timesPerDay} 次/日`;
return ( return (
<View style={[styles.card, { backgroundColor: colors.surface }]}> <TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface }]}
activeOpacity={0.9}
onPress={onPress}
>
<View style={styles.cardInfo}> <View style={styles.cardInfo}>
<Image <Image
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE} source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
@@ -144,15 +147,24 @@ export default function ManageMedicationsScreen() {
</ThemedText> </ThemedText>
</View> </View>
</View> </View>
<Switch <View style={styles.switchContainer}>
value={medication.isActive} <Switch
onValueChange={(value) => handleToggleMedication(medication, value)} value={medication.isActive}
disabled={updateLoading || pendingMedicationId === medication.id} onValueChange={(value) => handleToggleMedication(medication, value)}
trackColor={{ false: '#D9D9D9', true: colors.primary }} disabled={pendingMedicationId === medication.id}
thumbColor={medication.isActive ? '#fff' : '#fff'} trackColor={{ false: '#D9D9D9', true: colors.primary }}
ios_backgroundColor="#D9D9D9" thumbColor={medication.isActive ? '#fff' : '#fff'}
/> ios_backgroundColor="#D9D9D9"
</View> />
{pendingMedicationId === medication.id && (
<ActivityIndicator
size="small"
color={colors.primary}
style={styles.switchLoading}
/>
)}
</View>
</TouchableOpacity>
); );
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
// 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染 // 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染
@@ -166,11 +178,24 @@ export default function ManageMedicationsScreen() {
MedicationCard.displayName = 'MedicationCard'; MedicationCard.displayName = 'MedicationCard';
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
router.push({
pathname: '/medications/[medicationId]',
params: { medicationId },
});
}, []);
const renderMedicationCard = useCallback( const renderMedicationCard = useCallback(
(medication: Medication) => { (medication: Medication) => {
return <MedicationCard key={medication.id} medication={medication} />; return (
<MedicationCard
key={medication.id}
medication={medication}
onPress={() => handleOpenMedicationDetails(medication.id)}
/>
);
}, },
[handleToggleMedication, pendingMedicationId, updateLoading, colors] [handleToggleMedication, pendingMedicationId, colors, handleOpenMedicationDetails]
); );
return ( return (
@@ -398,4 +423,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
textAlign: 'center', textAlign: 'center',
}, },
switchContainer: {
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
switchLoading: {
position: 'absolute',
marginLeft: 30, // 确保加载指示器显示在开关旁边
},
}); });

View File

@@ -13,9 +13,10 @@ export type MedicationCardProps = {
medication: MedicationDisplayItem; 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;
onOpenDetails?: (medication: MedicationDisplayItem) => void;
}; };
export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) { export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }: MedicationCardProps) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -134,34 +135,7 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC
); );
} }
if (medication.status === 'missed') { // 只要没有服药,都可以显示立即服用
return (
<TouchableOpacity
activeOpacity={1}
disabled={true}
onPress={() => {
// 已错过的药物不能服用
console.log('已错过的药物不能服用');
}}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonMissed]}
glassEffectStyle="clear"
tintColor="rgba(156, 163, 175, 0.3)"
isInteractive={false}
>
<ThemedText style={styles.actionButtonTextMissed}></ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonMissed, styles.fallbackActionButtonMissed]}>
<ThemedText style={styles.actionButtonTextMissed}></ThemedText>
</View>
)}
</TouchableOpacity>
);
}
return ( return (
<TouchableOpacity <TouchableOpacity
activeOpacity={0.7} activeOpacity={0.7}
@@ -193,7 +167,12 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC
const statusChip = renderStatusBadge(); const statusChip = renderStatusBadge();
return ( return (
<View style={[styles.card, { shadowColor: colors.text }]}> <TouchableOpacity
style={[styles.card, { shadowColor: colors.text }]}
activeOpacity={onOpenDetails ? 0.92 : 1}
onPress={() => onOpenDetails?.(medication)}
disabled={!onOpenDetails}
>
<View style={[styles.cardSurface, { backgroundColor: colors.surface }]}> <View style={[styles.cardSurface, { backgroundColor: colors.surface }]}>
{statusChip ? <View style={styles.statusChipWrapper}>{statusChip}</View> : null} {statusChip ? <View style={styles.statusChipWrapper}>{statusChip}</View> : null}
<View style={styles.cardBody}> <View style={styles.cardBody}>
@@ -226,7 +205,7 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC
</View> </View>
</View> </View>
</View> </View>
</View> </TouchableOpacity>
); );
} }
@@ -361,4 +340,4 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: '#fff', color: '#fff',
}, },
}); });

View File

@@ -0,0 +1,265 @@
import * as Haptics from 'expo-haptics';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { height: screenHeight } = Dimensions.get('window');
interface ConfirmationSheetProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description?: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
loading?: boolean;
}
export function ConfirmationSheet({
visible,
onClose,
onConfirm,
title,
description,
confirmText = '确认',
cancelText = '取消',
destructive = false,
loading = false,
}: ConfirmationSheetProps) {
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(screenHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(visible);
useEffect(() => {
if (visible) {
setModalVisible(true);
}
}, [visible]);
useEffect(() => {
if (!modalVisible) {
return;
}
if (visible) {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
bounciness: 6,
speed: 12,
}),
]).start();
return;
}
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: screenHeight,
duration: 240,
useNativeDriver: true,
}),
]).start(() => {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
setModalVisible(false);
});
}, [visible, modalVisible, backdropOpacity, translateY]);
const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
};
const handleConfirm = () => {
if (loading) return;
Haptics.notificationAsync(
destructive ? Haptics.NotificationFeedbackType.Error : Haptics.NotificationFeedbackType.Success
);
onConfirm();
};
if (!modalVisible) {
return null;
}
return (
<Modal
visible={modalVisible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.backdrop,
{
opacity: backdropOpacity,
},
]}
>
<TouchableOpacity style={StyleSheet.absoluteFillObject} activeOpacity={1} onPress={handleCancel} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY }],
paddingBottom: Math.max(insets.bottom, 20),
},
]}
>
<View style={styles.handle} />
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
<View style={styles.actions}>
<TouchableOpacity
style={styles.cancelButton}
activeOpacity={0.85}
onPress={handleCancel}
disabled={loading}
>
<Text style={styles.cancelText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmButton,
destructive ? styles.destructiveButton : styles.primaryButton,
loading && styles.disabledButton,
]}
activeOpacity={0.85}
onPress={handleConfirm}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.confirmText}>{confirmText}</Text>
)}
</TouchableOpacity>
</View>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'transparent',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15, 23, 42, 0.45)',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 24,
paddingTop: 16,
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 16,
shadowOffset: { width: 0, height: -4 },
elevation: 16,
gap: 12,
},
handle: {
width: 50,
height: 4,
borderRadius: 2,
backgroundColor: '#E5E7EB',
alignSelf: 'center',
marginBottom: 8,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
},
description: {
fontSize: 15,
color: '#6B7280',
textAlign: 'center',
lineHeight: 22,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
cancelButton: {
flex: 1,
height: 56,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E5E7EB',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8FAFC',
},
cancelText: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
},
confirmButton: {
flex: 1,
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
shadowColor: 'rgba(239, 68, 68, 0.45)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
primaryButton: {
backgroundColor: '#2563EB',
},
destructiveButton: {
backgroundColor: '#EF4444',
},
disabledButton: {
opacity: 0.7,
},
confirmText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
});

View File

@@ -498,18 +498,25 @@ const medicationsSlice = createSlice({
// ==================== deleteMedication ==================== // ==================== deleteMedication ====================
builder builder
.addCase(deleteMedicationAction.pending, (state) => { .addCase(deleteMedicationAction.pending, (state) => {
console.log('[MEDICATIONS_SLICE] Delete operation pending');
state.loading.delete = true; state.loading.delete = true;
state.error = null; state.error = null;
}) })
.addCase(deleteMedicationAction.fulfilled, (state, action) => { .addCase(deleteMedicationAction.fulfilled, (state, action) => {
console.log('[MEDICATIONS_SLICE] Delete operation fulfilled', { deletedId: action.payload });
state.loading.delete = false; state.loading.delete = false;
const deletedId = action.payload; const deletedId = action.payload;
state.medications = state.medications.filter((m) => m.id !== deletedId); state.medications = state.medications.filter((m) => m.id !== deletedId);
state.activeMedications = state.activeMedications.filter( state.activeMedications = state.activeMedications.filter(
(m) => m.id !== deletedId (m) => m.id !== deletedId
); );
console.log('[MEDICATIONS_SLICE] Medications after delete', {
totalMedications: state.medications.length,
activeMedications: state.activeMedications.length
});
}) })
.addCase(deleteMedicationAction.rejected, (state, action) => { .addCase(deleteMedicationAction.rejected, (state, action) => {
console.log('[MEDICATIONS_SLICE] Delete operation rejected', action.error);
state.loading.delete = false; state.loading.delete = false;
state.error = action.error.message || '删除药物失败'; state.error = action.error.message || '删除药物失败';
}); });