feat(medication): 添加药物名称编辑和图片上传功能
This commit is contained in:
@@ -9,6 +9,7 @@ import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
@@ -30,6 +31,7 @@ import Voice from '@react-native-voice/voice';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@@ -73,6 +75,7 @@ export default function MedicationDetailScreen() {
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/medications' });
|
||||
|
||||
const medications = useAppSelector(selectMedications);
|
||||
const medicationFromStore = medications.find((item) => item.id === medicationId);
|
||||
@@ -89,6 +92,9 @@ export default function MedicationDetailScreen() {
|
||||
const [noteModalVisible, setNoteModalVisible] = useState(false);
|
||||
const [noteDraft, setNoteDraft] = useState(medication?.note ?? '');
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const [nameModalVisible, setNameModalVisible] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState(medicationFromStore?.name ?? '');
|
||||
const [nameSaving, setNameSaving] = useState(false);
|
||||
const [dictationActive, setDictationActive] = useState(false);
|
||||
const [dictationLoading, setDictationLoading] = useState(false);
|
||||
const isDictationSupported = Platform.OS === 'ios';
|
||||
@@ -98,6 +104,7 @@ export default function MedicationDetailScreen() {
|
||||
const [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
|
||||
const [deactivateLoading, setDeactivateLoading] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
|
||||
// AI 分析相关状态
|
||||
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
|
||||
@@ -151,6 +158,9 @@ export default function MedicationDetailScreen() {
|
||||
useEffect(() => {
|
||||
setNoteDraft(medication?.note ?? '');
|
||||
}, [medication?.note]);
|
||||
useEffect(() => {
|
||||
setNameDraft(medication?.name ?? '');
|
||||
}, [medication?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -480,6 +490,59 @@ export default function MedicationDetailScreen() {
|
||||
setNoteModalVisible(true);
|
||||
}, [medication?.note]);
|
||||
|
||||
const handleOpenNameModal = useCallback(() => {
|
||||
setNameDraft(medication?.name ?? '');
|
||||
setNameModalVisible(true);
|
||||
}, [medication?.name]);
|
||||
|
||||
const handleCloseNameModal = useCallback(() => {
|
||||
setNameModalVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((value: string) => {
|
||||
const sanitized = value.replace(/\n/g, '');
|
||||
const normalized = Array.from(sanitized).slice(0, 10).join('');
|
||||
setNameDraft(normalized);
|
||||
}, []);
|
||||
|
||||
const handleSaveName = useCallback(async () => {
|
||||
if (!medication || nameSaving) return;
|
||||
const trimmed = nameDraft.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert(
|
||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
||||
t('medications.detail.nameEdit.errorEmpty', { defaultValue: '药物名称不能为空' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Array.from(trimmed).length > 10) {
|
||||
Alert.alert(
|
||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
||||
t('medications.detail.nameEdit.errorTooLong', { defaultValue: '药物名称不能超过10个字' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
setNameSaving(true);
|
||||
try {
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medication.id,
|
||||
name: trimmed,
|
||||
})
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
setNameModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('更新药物名称失败', err);
|
||||
Alert.alert(
|
||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
||||
t('medications.detail.nameEdit.saveError', { defaultValue: '名称更新失败,请稍后再试' })
|
||||
);
|
||||
} finally {
|
||||
setNameSaving(false);
|
||||
}
|
||||
}, [dispatch, medication, nameDraft, nameSaving, t]);
|
||||
|
||||
const handleSaveNote = useCallback(async () => {
|
||||
if (!medication) return;
|
||||
const trimmed = noteDraft.trim();
|
||||
@@ -548,6 +611,146 @@ export default function MedicationDetailScreen() {
|
||||
}
|
||||
}, [medication?.photoUrl]);
|
||||
|
||||
// 处理图片选择(拍照或相册)
|
||||
const handleSelectPhoto = useCallback(async () => {
|
||||
if (!medication || uploading) return;
|
||||
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.selectTitle', { defaultValue: '选择图片' }),
|
||||
t('medications.detail.photo.selectMessage', { defaultValue: '请选择图片来源' }),
|
||||
[
|
||||
{
|
||||
text: t('medications.detail.photo.takePhoto', { defaultValue: '拍照' }),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.permissionDenied', { defaultValue: '权限不足' }),
|
||||
t('medications.detail.photo.cameraPermissionMessage', { defaultValue: '需要相机权限以拍摄药品照片' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.3,
|
||||
aspect: [9, 16],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
setPhotoPreview(asset.uri);
|
||||
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `medication-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/medications' }
|
||||
);
|
||||
|
||||
// 上传成功后更新药物信息
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medication.id,
|
||||
photoUrl: url,
|
||||
})
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
setPhotoPreview(null);
|
||||
} catch (uploadError) {
|
||||
console.error('[MEDICATION] 图片上传失败', uploadError);
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.uploadFailed', { defaultValue: '上传失败' }),
|
||||
t('medications.detail.photo.uploadFailedMessage', { defaultValue: '图片上传失败,请稍后重试' })
|
||||
);
|
||||
setPhotoPreview(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 拍照失败', error);
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.cameraFailed', { defaultValue: '拍照失败' }),
|
||||
t('medications.detail.photo.cameraFailedMessage', { defaultValue: '无法打开相机,请稍后再试' })
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('medications.detail.photo.chooseFromLibrary', { defaultValue: '从相册选择' }),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.permissionDenied', { defaultValue: '权限不足' }),
|
||||
t('medications.detail.photo.libraryPermissionMessage', { defaultValue: '需要相册权限以选择药品照片' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
setPhotoPreview(asset.uri);
|
||||
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `medication-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/medications' }
|
||||
);
|
||||
|
||||
// 上传成功后更新药物信息
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medication.id,
|
||||
photoUrl: url,
|
||||
})
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
setPhotoPreview(null);
|
||||
} catch (uploadError) {
|
||||
console.error('[MEDICATION] 图片上传失败', uploadError);
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.uploadFailed', { defaultValue: '上传失败' }),
|
||||
t('medications.detail.photo.uploadFailedMessage', { defaultValue: '图片上传失败,请稍后重试' })
|
||||
);
|
||||
setPhotoPreview(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 从相册选择失败', error);
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.libraryFailed', { defaultValue: '选择失败' }),
|
||||
t('medications.detail.photo.libraryFailedMessage', { defaultValue: '无法打开相册,请稍后再试' })
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('medications.detail.photo.cancel', { defaultValue: '取消' }),
|
||||
style: 'cancel',
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [medication, uploading, upload, dispatch, t]);
|
||||
|
||||
const handleStartDatePress = useCallback(() => {
|
||||
if (!medication) return;
|
||||
|
||||
@@ -849,25 +1052,63 @@ export default function MedicationDetailScreen() {
|
||||
>
|
||||
<View style={[styles.heroCard, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.heroInfo}>
|
||||
<TouchableOpacity
|
||||
style={styles.heroImageWrapper}
|
||||
onPress={handleImagePreview}
|
||||
activeOpacity={0.8}
|
||||
disabled={!medication.photoUrl}
|
||||
>
|
||||
<Image
|
||||
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
|
||||
style={styles.heroImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
{medication.photoUrl && (
|
||||
<View style={styles.imagePreviewHint}>
|
||||
<View style={styles.heroImageWrapper}>
|
||||
{/* 点击图片区域 - 触发上传 */}
|
||||
<TouchableOpacity
|
||||
style={styles.heroImageTouchable}
|
||||
onPress={handleSelectPhoto}
|
||||
activeOpacity={0.8}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Image
|
||||
source={
|
||||
photoPreview
|
||||
? { uri: photoPreview }
|
||||
: medication.photoUrl
|
||||
? { uri: medication.photoUrl }
|
||||
: DEFAULT_IMAGE
|
||||
}
|
||||
style={styles.heroImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 点击预览图标 - 触发预览(只在有照片且不在上传中时显示) */}
|
||||
{medication.photoUrl && !uploading && (
|
||||
<TouchableOpacity
|
||||
style={styles.imagePreviewHint}
|
||||
onPress={handleImagePreview}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<Ionicons name="expand-outline" size={14} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 上传中提示 */}
|
||||
{uploading && (
|
||||
<View style={styles.photoUploadingIndicator}>
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
|
||||
{t('medications.detail.photo.uploading', { defaultValue: '上传中...' })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View>
|
||||
<Text style={[styles.heroTitle, { color: colors.text }]}>{medication.name}</Text>
|
||||
</View>
|
||||
<View style={styles.heroTitleWrapper}>
|
||||
<View style={styles.heroTitleRow}>
|
||||
<Text style={[styles.heroTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{medication.name}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.heroEditButton}
|
||||
onPress={handleOpenNameModal}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="create-outline" size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[styles.heroMeta, { color: colors.textSecondary }]}>
|
||||
{dosageLabel} · {formLabel}
|
||||
</Text>
|
||||
@@ -1151,6 +1392,85 @@ export default function MedicationDetailScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={nameModalVisible}
|
||||
onRequestClose={handleCloseNameModal}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableOpacity style={styles.modalBackdrop} activeOpacity={1} onPress={handleCloseNameModal} />
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.modalHandle} />
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||
{t('medications.detail.nameEdit.title', { defaultValue: '编辑药物名称' })}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
|
||||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.nameInputWrapper,
|
||||
{
|
||||
backgroundColor: scheme === 'light' ? '#F2F2F7' : 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
value={nameDraft}
|
||||
onChangeText={handleNameChange}
|
||||
placeholder={t('medications.detail.nameEdit.placeholder', { defaultValue: '请输入药物名称' })}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.nameInput, { color: colors.text }]}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSaveName}
|
||||
selectionColor={colors.primary}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.nameInputCounterWrapper}>
|
||||
<Text style={[styles.nameInputCounter, { color: colors.textMuted }]}>
|
||||
{`${Array.from(nameDraft).length}/10`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActionContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalActionPrimary,
|
||||
{
|
||||
backgroundColor: colors.primary,
|
||||
shadowColor: colors.primary,
|
||||
},
|
||||
]}
|
||||
onPress={handleSaveName}
|
||||
activeOpacity={0.9}
|
||||
disabled={nameSaving}
|
||||
>
|
||||
{nameSaving ? (
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
|
||||
{t('medications.detail.nameEdit.saveButton', { defaultValue: '保存' })}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
@@ -1528,16 +1848,36 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroImageTouchable: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroImage: {
|
||||
width: '60%',
|
||||
height: '60%',
|
||||
borderRadius: '20%'
|
||||
},
|
||||
heroTitleWrapper: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
heroTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
heroEditButton: {
|
||||
padding: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
heroMeta: {
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
@@ -1673,6 +2013,22 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
nameInputWrapper: {
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
nameInput: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInputCounterWrapper: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
nameInputCounter: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
noteEditorWrapper: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 24,
|
||||
@@ -1897,6 +2253,25 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 10,
|
||||
padding: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
photoUploadingIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
uploadingText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#FFF',
|
||||
},
|
||||
// ImageViewing 组件样式
|
||||
imageViewerHeader: {
|
||||
|
||||
Reference in New Issue
Block a user