667 lines
19 KiB
TypeScript
667 lines
19 KiB
TypeScript
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
|
|
import { palette } from '@/constants/Colors';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
|
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
|
|
import {
|
|
addNewMedicalRecord,
|
|
deleteMedicalRecordItem,
|
|
fetchMedicalRecords,
|
|
selectHealthLoading,
|
|
selectMedicalRecords,
|
|
} from '@/store/healthSlice';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import dayjs from 'dayjs';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import { Image } from 'expo-image';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
FlatList,
|
|
Modal,
|
|
Platform,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import ImageViewing from 'react-native-image-viewing';
|
|
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
|
|
|
export function MedicalRecordsTab() {
|
|
const dispatch = useAppDispatch();
|
|
const medicalRecords = useAppSelector(selectMedicalRecords);
|
|
const records = medicalRecords?.records || [];
|
|
const prescriptions = medicalRecords?.prescriptions || [];
|
|
const isLoading = useAppSelector(selectHealthLoading);
|
|
|
|
// COS 上传
|
|
const { upload: uploadToCos, uploading: isUploading } = useCosUpload({
|
|
prefix: 'images/health/medical-records'
|
|
});
|
|
|
|
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
|
|
const [isModalVisible, setModalVisible] = useState(false);
|
|
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
|
|
|
// Form State
|
|
const [title, setTitle] = useState('');
|
|
const [date, setDate] = useState(new Date());
|
|
const [images, setImages] = useState<string[]>([]);
|
|
const [note, setNote] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Image Viewer State
|
|
const [viewerVisible, setViewerVisible] = useState(false);
|
|
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
|
|
|
|
useEffect(() => {
|
|
dispatch(fetchMedicalRecords());
|
|
}, [dispatch]);
|
|
|
|
const currentList = activeTab === 'medical_record' ? records : prescriptions;
|
|
|
|
const handleTabPress = (tab: MedicalRecordType) => {
|
|
setActiveTab(tab);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setTitle('');
|
|
setDate(new Date());
|
|
setImages([]);
|
|
setNote('');
|
|
};
|
|
|
|
const openAddModal = () => {
|
|
resetForm();
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handlePickImage = async () => {
|
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (status !== 'granted') {
|
|
Alert.alert('需要权限', '请允许访问相册以上传图片');
|
|
return;
|
|
}
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
allowsEditing: true,
|
|
quality: 0.8,
|
|
});
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
setImages([...images, result.assets[0].uri]);
|
|
}
|
|
};
|
|
|
|
const handleTakePhoto = async () => {
|
|
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
|
if (status !== 'granted') {
|
|
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
|
|
return;
|
|
}
|
|
|
|
const result = await ImagePicker.launchCameraAsync({
|
|
allowsEditing: true,
|
|
quality: 0.8,
|
|
});
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
setImages([...images, result.assets[0].uri]);
|
|
}
|
|
};
|
|
|
|
const handlePickDocument = async () => {
|
|
try {
|
|
const result = await DocumentPicker.getDocumentAsync({
|
|
type: ['application/pdf', 'image/*'],
|
|
copyToCacheDirectory: true,
|
|
multiple: false,
|
|
});
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
setImages([...images, result.assets[0].uri]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error picking document:', error);
|
|
Alert.alert('错误', '选择文件失败');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!title.trim()) {
|
|
Alert.alert('提示', '请输入标题');
|
|
return;
|
|
}
|
|
if (images.length === 0) {
|
|
Alert.alert('提示', '请至少上传一张图片');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
// 1. 上传所有图片到 COS
|
|
const uploadPromises = images.map(async (uri) => {
|
|
const result = await uploadToCos({ uri });
|
|
return result.url;
|
|
});
|
|
|
|
const uploadedUrls = await Promise.all(uploadPromises);
|
|
|
|
// 2. 创建就医资料记录
|
|
await dispatch(addNewMedicalRecord({
|
|
type: activeTab,
|
|
title: title.trim(),
|
|
date: dayjs(date).format('YYYY-MM-DD'),
|
|
images: uploadedUrls,
|
|
note: note.trim() || undefined,
|
|
})).unwrap();
|
|
|
|
setModalVisible(false);
|
|
resetForm();
|
|
} catch (error: any) {
|
|
console.error('保存失败:', error);
|
|
const errorMessage = error?.message || '保存失败,请重试';
|
|
Alert.alert('错误', errorMessage);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (item: MedicalRecordItem) => {
|
|
Alert.alert(
|
|
'确认删除',
|
|
'确定要删除这条记录吗?',
|
|
[
|
|
{ text: '取消', style: 'cancel' },
|
|
{
|
|
text: '删除',
|
|
style: 'destructive',
|
|
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
const handleViewImages = (item: MedicalRecordItem) => {
|
|
if (item.images && item.images.length > 0) {
|
|
setCurrentViewerImages(item.images.map(uri => ({ uri })));
|
|
setViewerVisible(true);
|
|
}
|
|
};
|
|
|
|
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
|
|
<MedicalRecordCard
|
|
item={item}
|
|
onPress={handleViewImages}
|
|
onDelete={handleDelete}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Segmented Control */}
|
|
<View style={styles.segmentContainer}>
|
|
<TouchableOpacity
|
|
style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
|
|
onPress={() => handleTabPress('medical_record')}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
|
|
病历资料
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
|
|
onPress={() => handleTabPress('prescription')}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
|
|
处方单据
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Content List */}
|
|
<View style={styles.contentContainer}>
|
|
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={palette.purple[500]} />
|
|
</View>
|
|
) : currentList.length > 0 ? (
|
|
<FlatList
|
|
data={currentList}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => item.id}
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={styles.listContent}
|
|
scrollEnabled={false} // Since it's inside a parent ScrollView
|
|
/>
|
|
) : (
|
|
<View style={styles.emptyState}>
|
|
<View style={styles.emptyIconContainer}>
|
|
<Ionicons
|
|
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
|
|
size={48}
|
|
color={palette.gray[300]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.emptyText}>
|
|
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
|
|
</Text>
|
|
<Text style={styles.emptySubtext}>
|
|
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Add Button */}
|
|
<TouchableOpacity
|
|
style={styles.fab}
|
|
onPress={openAddModal}
|
|
activeOpacity={0.9}
|
|
>
|
|
<LinearGradient
|
|
colors={[palette.purple[500], palette.purple[700]]}
|
|
style={styles.fabGradient}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
>
|
|
<Ionicons name="add" size={28} color="#FFFFFF" />
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
|
|
{/* Add/Edit Modal */}
|
|
<Modal
|
|
visible={isModalVisible}
|
|
animationType="slide"
|
|
presentationStyle="pageSheet"
|
|
onRequestClose={() => setModalVisible(false)}
|
|
>
|
|
<View style={styles.modalContainer}>
|
|
<View style={styles.modalHeader}>
|
|
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
|
|
<Text style={styles.modalCloseText}>取消</Text>
|
|
</TouchableOpacity>
|
|
<Text style={styles.modalTitle}>
|
|
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={handleSubmit}
|
|
style={[styles.modalSaveButton, (isSubmitting || isUploading) && styles.modalSaveButtonDisabled]}
|
|
disabled={isSubmitting || isUploading}
|
|
>
|
|
{(isSubmitting || isUploading) ? (
|
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
) : (
|
|
<Text style={styles.modalSaveText}>保存</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.formContainer}>
|
|
{/* Title Input */}
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>标题 <Text style={styles.required}>*</Text></Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
|
|
value={title}
|
|
onChangeText={setTitle}
|
|
placeholderTextColor={palette.gray[400]}
|
|
/>
|
|
</View>
|
|
|
|
{/* Date Picker */}
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>日期</Text>
|
|
<TouchableOpacity
|
|
style={styles.dateInput}
|
|
onPress={() => setDatePickerVisibility(true)}
|
|
>
|
|
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
|
|
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Images */}
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>图片资料 <Text style={styles.required}>*</Text></Text>
|
|
<View style={styles.imageGrid}>
|
|
{images.map((uri, index) => {
|
|
const isPdf = uri.toLowerCase().endsWith('.pdf');
|
|
return (
|
|
<View key={index} style={styles.imagePreviewContainer}>
|
|
{isPdf ? (
|
|
<View style={[styles.imagePreview, styles.pdfPreview]}>
|
|
<Ionicons name="document-text" size={32} color="#EF4444" />
|
|
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
|
|
</View>
|
|
) : (
|
|
<Image
|
|
source={{ uri }}
|
|
style={styles.imagePreview}
|
|
contentFit="cover"
|
|
/>
|
|
)}
|
|
<TouchableOpacity
|
|
style={styles.removeImageButton}
|
|
onPress={() => setImages(images.filter((_, i) => i !== index))}
|
|
>
|
|
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
{images.length < 9 && (
|
|
<TouchableOpacity style={styles.addImageButton} onPress={() => {
|
|
Alert.alert(
|
|
'上传文件',
|
|
'请选择上传方式',
|
|
[
|
|
{ text: '拍照', onPress: handleTakePhoto },
|
|
{ text: '从相册选择', onPress: handlePickImage },
|
|
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
|
|
{ text: '取消', style: 'cancel' },
|
|
]
|
|
);
|
|
}}>
|
|
<Ionicons name="add" size={32} color={palette.purple[500]} />
|
|
<Text style={styles.addImageText}>上传</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Note */}
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>备注</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea]}
|
|
placeholder="添加备注信息..."
|
|
value={note}
|
|
onChangeText={setNote}
|
|
multiline
|
|
numberOfLines={4}
|
|
placeholderTextColor={palette.gray[400]}
|
|
textAlignVertical="top"
|
|
/>
|
|
</View>
|
|
|
|
</View>
|
|
</View>
|
|
|
|
<DateTimePickerModal
|
|
isVisible={isDatePickerVisible}
|
|
mode="date"
|
|
onConfirm={(d) => {
|
|
setDate(d);
|
|
setDatePickerVisibility(false);
|
|
}}
|
|
onCancel={() => setDatePickerVisibility(false)}
|
|
maximumDate={new Date()}
|
|
locale="zh_CN"
|
|
confirmTextIOS="确定"
|
|
cancelTextIOS="取消"
|
|
/>
|
|
</Modal>
|
|
|
|
<ImageViewing
|
|
images={currentViewerImages}
|
|
imageIndex={0}
|
|
visible={viewerVisible}
|
|
onRequestClose={() => setViewerVisible(false)}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
segmentContainer: {
|
|
flexDirection: 'row',
|
|
backgroundColor: '#F3F4F6',
|
|
borderRadius: 12,
|
|
padding: 4,
|
|
marginBottom: 16,
|
|
},
|
|
segmentButton: {
|
|
flex: 1,
|
|
paddingVertical: 10,
|
|
alignItems: 'center',
|
|
borderRadius: 10,
|
|
},
|
|
segmentButtonActive: {
|
|
backgroundColor: '#FFFFFF',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 2,
|
|
elevation: 1,
|
|
},
|
|
segmentText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: '#6B7280',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
segmentTextActive: {
|
|
color: palette.purple[600],
|
|
fontWeight: '600',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
contentContainer: {
|
|
minHeight: 300,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingVertical: 40,
|
|
},
|
|
listContent: {
|
|
paddingBottom: 80,
|
|
},
|
|
emptyState: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginTop: 40,
|
|
paddingHorizontal: 40,
|
|
},
|
|
emptyIconContainer: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 40,
|
|
backgroundColor: '#F9FAFB',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
emptyText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#374151',
|
|
marginBottom: 8,
|
|
fontFamily: 'AliBold',
|
|
},
|
|
emptySubtext: {
|
|
fontSize: 13,
|
|
color: '#9CA3AF',
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
fab: {
|
|
position: 'absolute',
|
|
right: 16,
|
|
bottom: 16,
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
shadowColor: palette.purple[500],
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
elevation: 6,
|
|
},
|
|
fabGradient: {
|
|
width: '100%',
|
|
height: '100%',
|
|
borderRadius: 28,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
// Modal Styles
|
|
modalContainer: {
|
|
flex: 1,
|
|
backgroundColor: '#F9FAFB',
|
|
},
|
|
modalHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
backgroundColor: '#FFFFFF',
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: '#E5E7EB',
|
|
paddingTop: Platform.OS === 'ios' ? 12 : 12,
|
|
},
|
|
modalCloseButton: {
|
|
padding: 8,
|
|
},
|
|
modalCloseText: {
|
|
fontSize: 16,
|
|
color: '#6B7280',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
modalTitle: {
|
|
fontSize: 17,
|
|
fontWeight: '600',
|
|
color: '#111827',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
modalSaveButton: {
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
backgroundColor: palette.purple[600],
|
|
borderRadius: 6,
|
|
},
|
|
modalSaveButtonDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
modalSaveText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
formContainer: {
|
|
padding: 16,
|
|
},
|
|
inputGroup: {
|
|
marginBottom: 20,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: '#374151',
|
|
marginBottom: 8,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
required: {
|
|
color: palette.error[500],
|
|
},
|
|
input: {
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 12,
|
|
fontSize: 16,
|
|
color: '#111827',
|
|
borderWidth: 1,
|
|
borderColor: '#E5E7EB',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
textArea: {
|
|
height: 100,
|
|
textAlignVertical: 'top',
|
|
},
|
|
dateInput: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#E5E7EB',
|
|
},
|
|
dateText: {
|
|
fontSize: 16,
|
|
color: '#111827',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
imageGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 12,
|
|
},
|
|
imagePreviewContainer: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
},
|
|
imagePreview: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
pdfPreview: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#F3F4F6',
|
|
},
|
|
pdfText: {
|
|
fontSize: 10,
|
|
marginTop: 4,
|
|
color: '#EF4444',
|
|
fontWeight: '600',
|
|
},
|
|
removeImageButton: {
|
|
position: 'absolute',
|
|
top: 2,
|
|
right: 2,
|
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
|
borderRadius: 10,
|
|
},
|
|
addImageButton: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: palette.purple[200],
|
|
borderStyle: 'dashed',
|
|
backgroundColor: palette.purple[50],
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
addImageText: {
|
|
fontSize: 12,
|
|
color: palette.purple[600],
|
|
marginTop: 4,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
});
|