feat(medication): 添加药物名称编辑和图片上传功能

This commit is contained in:
richarjiang
2025-11-19 16:08:52 +08:00
parent 6039d0a778
commit ee60f0756e

View File

@@ -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: {