549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
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, // 确保加载指示器显示在开关旁边
|
||
},
|
||
});
|