Files
digital-pilates/app/medications/manage-medications.tsx
richarjiang be55c6f43e feat(medications): 添加药品停用确认弹窗
在药品详情页和管理页面添加停用药品的确认弹窗,防止用户误操作。
当用户尝试停用药品时,会显示确认对话框提醒用户停用后当天已生成的用药计划会被删除且无法恢复。
2025-11-12 10:49:39 +08:00

549 lines
16 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 { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
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 { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
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: { key: FilterType; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'inactive', label: '已停用' },
];
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.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 [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
const [medicationToDeactivate, setMedicationToDeactivate] = useState<Medication | null>(null);
const [deactivateLoading, setDeactivateLoading] = useState(false);
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;
// 如果是关闭激活状态,显示确认弹窗
if (!nextValue) {
setMedicationToDeactivate(medication);
setDeactivateSheetVisible(true);
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]
);
const handleDeactivateMedication = useCallback(async () => {
if (!medicationToDeactivate || deactivateLoading) return;
try {
setDeactivateLoading(true);
setDeactivateSheetVisible(false); // 立即关闭确认对话框
await dispatch(
updateMedicationAction({
id: medicationToDeactivate.id,
isActive: false,
})
).unwrap();
} catch (error) {
console.error('停用药物失败', error);
Alert.alert('操作失败', '停用药物时发生问题,请稍后重试。');
} finally {
setDeactivateLoading(false);
setMedicationToDeactivate(null);
}
}, [dispatch, medicationToDeactivate, deactivateLoading]);
// 创建独立的药品卡片组件,使用 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}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<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
activeOpacity={0.85}
onPress={() => router.push('/medications/add-medication')}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.addButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="plus" size={20} color="#333" />
</GlassView>
) : (
<View style={[styles.addButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={20} color="#333" />
</View>
)}
</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>
{/* 停用药品确认弹窗 */}
{medicationToDeactivate ? (
<ConfirmationSheet
visible={deactivateSheetVisible}
onClose={() => {
setDeactivateSheetVisible(false);
setMedicationToDeactivate(null);
}}
onConfirm={handleDeactivateMedication}
title={`停用 ${medicationToDeactivate.name}`}
description="停用后,当天已生成的用药计划会一并删除,且无法恢复。"
confirmText="确认停用"
cancelText="取消"
destructive
loading={deactivateLoading}
/>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
position: 'relative',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
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',
overflow: 'hidden',
},
fallbackAddButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
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, // 确保加载指示器显示在开关旁边
},
});