feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePickerModal';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
@@ -12,10 +13,11 @@ 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';
|
||||
import {
|
||||
analyzeMedicationV2,
|
||||
confirmMedicationRecognition,
|
||||
getMedicationById,
|
||||
getMedicationRecognitionStatus,
|
||||
getMedicationRecords,
|
||||
} from '@/services/medications';
|
||||
import {
|
||||
@@ -24,7 +26,12 @@ import {
|
||||
selectMedications,
|
||||
updateMedicationAction,
|
||||
} from '@/store/medicationsSlice';
|
||||
import type { Medication, MedicationAiAnalysisV2, MedicationForm } from '@/types/medication';
|
||||
import type {
|
||||
Medication,
|
||||
MedicationAiAnalysisV2,
|
||||
MedicationAiRecognitionResult,
|
||||
MedicationForm,
|
||||
} from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import Voice from '@react-native-voice/voice';
|
||||
@@ -63,10 +70,12 @@ type RecordsSummary = {
|
||||
|
||||
export default function MedicationDetailScreen() {
|
||||
const { t } = useI18n();
|
||||
const params = useLocalSearchParams<{ medicationId?: string }>();
|
||||
const params = useLocalSearchParams<{ medicationId?: string; aiTaskId?: string; cover?: string }>();
|
||||
const medicationId = Array.isArray(params.medicationId)
|
||||
? params.medicationId[0]
|
||||
: params.medicationId;
|
||||
const aiTaskId = Array.isArray(params.aiTaskId) ? params.aiTaskId[0] : params.aiTaskId;
|
||||
const coverFromParams = Array.isArray(params.cover) ? params.cover[0] : params.cover;
|
||||
const dispatch = useAppDispatch();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
@@ -79,9 +88,10 @@ export default function MedicationDetailScreen() {
|
||||
|
||||
const medications = useAppSelector(selectMedications);
|
||||
const medicationFromStore = medications.find((item) => item.id === medicationId);
|
||||
const isAiDraft = Boolean(aiTaskId);
|
||||
|
||||
const [medication, setMedication] = useState<Medication | null>(medicationFromStore ?? null);
|
||||
const [loading, setLoading] = useState(!medicationFromStore);
|
||||
const [loading, setLoading] = useState(isAiDraft ? true : !medicationFromStore);
|
||||
const [summary, setSummary] = useState<RecordsSummary>({
|
||||
takenCount: 0,
|
||||
startedDays: null,
|
||||
@@ -105,12 +115,47 @@ export default function MedicationDetailScreen() {
|
||||
const [deactivateLoading, setDeactivateLoading] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
|
||||
const buildAiDraftMedication = useCallback(
|
||||
(result: MedicationAiRecognitionResult): Medication => {
|
||||
const timeList =
|
||||
result.medicationTimes && result.medicationTimes.length
|
||||
? result.medicationTimes
|
||||
: Array.from({ length: result.timesPerDay ?? 1 }, (_, idx) => {
|
||||
const base = ['08:00', '12:30', '18:30', '22:00'];
|
||||
return base[idx] ?? base[0];
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'ai-draft',
|
||||
userId: '',
|
||||
name: result.name || 'AI 识别药物',
|
||||
photoUrl: result.photoUrl || coverFromParams || undefined,
|
||||
form: result.form || 'other',
|
||||
dosageValue: result.dosageValue ?? 1,
|
||||
dosageUnit: result.dosageUnit || '次',
|
||||
timesPerDay: result.timesPerDay ?? Math.max(timeList.length, 1),
|
||||
medicationTimes: timeList,
|
||||
startDate: result.startDate || new Date().toISOString(),
|
||||
endDate: result.endDate ?? null,
|
||||
repeatPattern: 'daily',
|
||||
note: result.note || '',
|
||||
aiAnalysis: result ? JSON.stringify(result) : undefined,
|
||||
isActive: true,
|
||||
deleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
[coverFromParams]
|
||||
);
|
||||
|
||||
// AI 分析相关状态
|
||||
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
|
||||
const [aiAnalysisResult, setAiAnalysisResult] = useState<MedicationAiAnalysisV2 | null>(null);
|
||||
const [aiAnalysisError, setAiAnalysisError] = useState<string | null>(null);
|
||||
const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false);
|
||||
const [aiDraftSaving, setAiDraftSaving] = useState(false);
|
||||
|
||||
// 剂量选择相关状态
|
||||
const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
|
||||
@@ -127,6 +172,10 @@ export default function MedicationDetailScreen() {
|
||||
medicationFromStore?.form ?? 'capsule'
|
||||
);
|
||||
|
||||
// 有效期选择相关状态
|
||||
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
|
||||
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
|
||||
|
||||
// ScrollView 引用,用于滚动到底部
|
||||
const scrollViewRef = React.useRef<ScrollView>(null);
|
||||
|
||||
@@ -184,6 +233,13 @@ export default function MedicationDetailScreen() {
|
||||
hasMedicationFromStore: !!medicationFromStore,
|
||||
deleteLoading
|
||||
});
|
||||
|
||||
if (isAiDraft) {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
abortController.abort();
|
||||
};
|
||||
}
|
||||
|
||||
// 如果正在删除操作中,不执行任何操作
|
||||
if (deleteLoading) {
|
||||
@@ -246,9 +302,57 @@ export default function MedicationDetailScreen() {
|
||||
};
|
||||
}, [medicationId, medicationFromStore, deleteLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!aiTaskId) return;
|
||||
|
||||
const hydrateFromAi = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getMedicationRecognitionStatus(aiTaskId);
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.status !== 'completed' || !data.result) {
|
||||
setError('AI 识别结果暂不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = buildAiDraftMedication(data.result);
|
||||
setMedication(draft);
|
||||
setAiAnalysisResult({
|
||||
suitableFor: data.result.suitableFor ?? [],
|
||||
unsuitableFor: data.result.unsuitableFor ?? [],
|
||||
mainIngredients: data.result.mainIngredients ?? [],
|
||||
mainUsage: data.result.mainUsage ?? '',
|
||||
sideEffects: data.result.sideEffects ?? [],
|
||||
storageAdvice: data.result.storageAdvice ?? [],
|
||||
healthAdvice: data.result.healthAdvice ?? [],
|
||||
});
|
||||
setSummary({ takenCount: 0, startedDays: null });
|
||||
setSummaryLoading(false);
|
||||
setError(null);
|
||||
setAiAnalysisLocked(false);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('[MEDICATION_DETAIL] 加载 AI 草稿失败', err);
|
||||
setError('识别结果加载失败,请返回重试');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hydrateFromAi();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [aiTaskId, buildAiDraftMedication]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (!medicationId) {
|
||||
if (!medicationId || isAiDraft) {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
@@ -403,7 +507,7 @@ export default function MedicationDetailScreen() {
|
||||
}, [dictationActive]);
|
||||
|
||||
const handleToggleMedication = async (nextValue: boolean) => {
|
||||
if (!medication || updatePending) return;
|
||||
if (!medication || updatePending || isAiDraft) return;
|
||||
|
||||
// 如果是关闭激活状态,显示确认弹窗
|
||||
if (!nextValue) {
|
||||
@@ -422,16 +526,6 @@ export default function MedicationDetailScreen() {
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
if (nextValue) {
|
||||
// 如果激活了药品,安排通知
|
||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 处理药品通知失败:', error);
|
||||
// 不影响药品状态切换的成功流程,只记录错误
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('切换药品状态失败', err);
|
||||
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
|
||||
@@ -441,7 +535,7 @@ export default function MedicationDetailScreen() {
|
||||
};
|
||||
|
||||
const handleDeactivateMedication = useCallback(async () => {
|
||||
if (!medication || deactivateLoading) return;
|
||||
if (!medication || deactivateLoading || isAiDraft) return;
|
||||
|
||||
try {
|
||||
setDeactivateLoading(true);
|
||||
@@ -455,13 +549,6 @@ export default function MedicationDetailScreen() {
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
|
||||
// 取消该药品的通知
|
||||
try {
|
||||
await medicationNotificationService.cancelMedicationNotifications(updated.id);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 取消药品通知失败:', error);
|
||||
// 不影响药品状态切换的成功流程,只记录错误
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停用药物失败', error);
|
||||
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
|
||||
@@ -492,6 +579,25 @@ export default function MedicationDetailScreen() {
|
||||
}
|
||||
}, [medication, t]);
|
||||
|
||||
// 计算有效期显示
|
||||
const expiryDateLabel = useMemo(() => {
|
||||
if (!medication?.expiryDate) return '未设置';
|
||||
|
||||
const expiry = dayjs(medication.expiryDate);
|
||||
const today = dayjs();
|
||||
const daysUntilExpiry = expiry.diff(today, 'day');
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return `${expiry.format('YYYY年M月D日')} (已过期)`;
|
||||
} else if (daysUntilExpiry === 0) {
|
||||
return `${expiry.format('YYYY年M月D日')} (今天到期)`;
|
||||
} else if (daysUntilExpiry <= 30) {
|
||||
return `${expiry.format('YYYY年M月D日')} (${daysUntilExpiry}天后到期)`;
|
||||
} else {
|
||||
return expiry.format('YYYY年M月D日');
|
||||
}
|
||||
}, [medication?.expiryDate]);
|
||||
|
||||
const reminderTimes = medication?.medicationTimes?.length
|
||||
? medication.medicationTimes.join('、')
|
||||
: t('medications.manage.reminderNotSet');
|
||||
@@ -539,18 +645,23 @@ export default function MedicationDetailScreen() {
|
||||
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个字' })
|
||||
'提示',
|
||||
'药物名称不能超过10个字'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) => (prev ? { ...prev, name: trimmed } : prev));
|
||||
setNameModalVisible(false);
|
||||
return;
|
||||
}
|
||||
setNameSaving(true);
|
||||
try {
|
||||
const updated = await dispatch(
|
||||
@@ -564,17 +675,22 @@ export default function MedicationDetailScreen() {
|
||||
} 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]);
|
||||
}, [dispatch, isAiDraft, medication, nameDraft, nameSaving, t]);
|
||||
|
||||
const handleSaveNote = useCallback(async () => {
|
||||
if (!medication) return;
|
||||
const trimmed = noteDraft.trim();
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) => (prev ? { ...prev, note: trimmed } : prev));
|
||||
closeNoteModal();
|
||||
return;
|
||||
}
|
||||
setNoteSaving(true);
|
||||
try {
|
||||
const updated = await dispatch(
|
||||
@@ -591,7 +707,7 @@ export default function MedicationDetailScreen() {
|
||||
} finally {
|
||||
setNoteSaving(false);
|
||||
}
|
||||
}, [closeNoteModal, dispatch, medication, noteDraft]);
|
||||
}, [closeNoteModal, dispatch, isAiDraft, medication, noteDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serviceInfo.canUseService) {
|
||||
@@ -620,14 +736,6 @@ export default function MedicationDetailScreen() {
|
||||
setDeleteLoading(true);
|
||||
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
||||
|
||||
// 先取消该药品的通知
|
||||
try {
|
||||
await medicationNotificationService.cancelMedicationNotifications(medication.id);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 取消药品通知失败:', error);
|
||||
// 不影响药品删除的成功流程,只记录错误
|
||||
}
|
||||
|
||||
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
||||
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
||||
router.back();
|
||||
@@ -650,11 +758,11 @@ export default function MedicationDetailScreen() {
|
||||
if (!medication || uploading) return;
|
||||
|
||||
Alert.alert(
|
||||
t('medications.detail.photo.selectTitle', { defaultValue: '选择图片' }),
|
||||
t('medications.detail.photo.selectMessage', { defaultValue: '请选择图片来源' }),
|
||||
t('medications.add.photo.selectTitle'),
|
||||
t('medications.add.photo.selectMessage'),
|
||||
[
|
||||
{
|
||||
text: t('medications.detail.photo.takePhoto', { defaultValue: '拍照' }),
|
||||
text: t('medications.add.photo.camera'),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
@@ -689,6 +797,12 @@ export default function MedicationDetailScreen() {
|
||||
{ prefix: 'images/medications' }
|
||||
);
|
||||
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
|
||||
setPhotoPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 上传成功后更新药物信息
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
@@ -716,7 +830,7 @@ export default function MedicationDetailScreen() {
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('medications.detail.photo.chooseFromLibrary', { defaultValue: '从相册选择' }),
|
||||
text: t('medications.add.photo.album'),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -750,6 +864,12 @@ export default function MedicationDetailScreen() {
|
||||
{ prefix: 'images/medications' }
|
||||
);
|
||||
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
|
||||
setPhotoPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 上传成功后更新药物信息
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
@@ -777,13 +897,13 @@ export default function MedicationDetailScreen() {
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('medications.detail.photo.cancel', { defaultValue: '取消' }),
|
||||
text: t('medications.add.photo.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [medication, uploading, upload, dispatch, t]);
|
||||
}, [dispatch, isAiDraft, medication, t, upload, uploading]);
|
||||
|
||||
const handleStartDatePress = useCallback(() => {
|
||||
if (!medication) return;
|
||||
@@ -792,22 +912,16 @@ export default function MedicationDetailScreen() {
|
||||
let message;
|
||||
if (medication.endDate) {
|
||||
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
|
||||
message = t('medications.detail.plan.periodMessage', {
|
||||
startDate,
|
||||
endDateInfo: t('medications.detail.plan.periodMessage', { endDate })
|
||||
});
|
||||
message = `从 ${startDate} 至 ${endDate}`;
|
||||
} else {
|
||||
message = t('medications.detail.plan.periodMessage', {
|
||||
startDate,
|
||||
endDateInfo: t('medications.detail.plan.longTermPlan')
|
||||
});
|
||||
message = `从 ${startDate} 至长期`;
|
||||
}
|
||||
|
||||
Alert.alert(t('medications.detail.sections.plan'), message);
|
||||
Alert.alert('服药周期', message);
|
||||
}, [medication, t]);
|
||||
|
||||
const handleTimePress = useCallback(() => {
|
||||
Alert.alert(t('medications.detail.plan.time'), t('medications.detail.plan.timeMessage', { times: reminderTimes }));
|
||||
Alert.alert('服药时间', `每日提醒时间:${reminderTimes}`);
|
||||
}, [reminderTimes, t]);
|
||||
|
||||
const handleDosagePress = useCallback(() => {
|
||||
@@ -822,7 +936,18 @@ export default function MedicationDetailScreen() {
|
||||
|
||||
const handleFrequencyPress = useCallback(() => {
|
||||
if (!medication) return;
|
||||
// 跳转到独立的频率编辑页面
|
||||
|
||||
// AI 草稿模式:显示提示,暂不支持编辑频率
|
||||
if (isAiDraft) {
|
||||
Alert.alert(
|
||||
'提示',
|
||||
'请先保存药物信息后,再编辑服药频率',
|
||||
[{ text: '知道了', style: 'default' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常模式:跳转到独立的频率编辑页面
|
||||
router.push({
|
||||
pathname: ROUTES.MEDICATION_EDIT_FREQUENCY,
|
||||
params: {
|
||||
@@ -833,7 +958,43 @@ export default function MedicationDetailScreen() {
|
||||
medicationTimes: medication.medicationTimes.join(','),
|
||||
},
|
||||
});
|
||||
}, [medication, router]);
|
||||
}, [medication, router, isAiDraft]);
|
||||
|
||||
const handleExpiryDatePress = useCallback(() => {
|
||||
if (!medication) return;
|
||||
setExpiryDatePickerValue(medication.expiryDate ? new Date(medication.expiryDate) : new Date());
|
||||
setExpiryDatePickerVisible(true);
|
||||
}, [medication]);
|
||||
|
||||
const handleExpiryDateConfirm = useCallback(async (date: Date) => {
|
||||
if (!medication || updatePending) return;
|
||||
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) =>
|
||||
prev
|
||||
? { ...prev, expiryDate: dayjs(date).endOf('day').toISOString() }
|
||||
: prev
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatePending(true);
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medication.id,
|
||||
expiryDate: dayjs(date).endOf('day').toISOString(),
|
||||
})
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
|
||||
} catch (err) {
|
||||
console.error('更新有效期失败', err);
|
||||
Alert.alert('更新失败', '有效期更新失败,请稍后重试');
|
||||
} finally {
|
||||
setUpdatePending(false);
|
||||
}
|
||||
}, [dispatch, isAiDraft, medication, updatePending]);
|
||||
|
||||
const renderAdviceCard = useCallback(
|
||||
(
|
||||
@@ -878,6 +1039,15 @@ export default function MedicationDetailScreen() {
|
||||
if (dosageValuePicker === medication.dosageValue && dosageUnitPicker === medication.dosageUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) =>
|
||||
prev
|
||||
? { ...prev, dosageValue: dosageValuePicker, dosageUnit: dosageUnitPicker }
|
||||
: prev
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatePending(true);
|
||||
@@ -890,20 +1060,13 @@ export default function MedicationDetailScreen() {
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
// 不影响药品更新的成功流程,只记录错误
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('更新剂量失败', err);
|
||||
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
|
||||
} finally {
|
||||
setUpdatePending(false);
|
||||
}
|
||||
}, [dispatch, dosageUnitPicker, dosageValuePicker, medication, updatePending]);
|
||||
}, [dispatch, dosageUnitPicker, dosageValuePicker, isAiDraft, medication, updatePending]);
|
||||
|
||||
const confirmFormPicker = useCallback(async () => {
|
||||
if (!medication || updatePending) return;
|
||||
@@ -914,6 +1077,11 @@ export default function MedicationDetailScreen() {
|
||||
if (formPicker === medication.form) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAiDraft) {
|
||||
setMedication((prev) => (prev ? { ...prev, form: formPicker } : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatePending(true);
|
||||
@@ -925,24 +1093,17 @@ export default function MedicationDetailScreen() {
|
||||
).unwrap();
|
||||
setMedication(updated);
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
// 不影响药品更新的成功流程,只记录错误
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('更新剂型失败', err);
|
||||
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
|
||||
} finally {
|
||||
setUpdatePending(false);
|
||||
}
|
||||
}, [dispatch, formPicker, medication, updatePending]);
|
||||
}, [dispatch, formPicker, isAiDraft, medication, updatePending]);
|
||||
|
||||
// AI 分析处理函数
|
||||
const handleAiAnalysis = useCallback(async () => {
|
||||
if (!medication || aiAnalysisLoading) return;
|
||||
if (!medication || aiAnalysisLoading || isAiDraft) return;
|
||||
|
||||
// 1. 先验证用户是否登录
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
@@ -1001,7 +1162,34 @@ export default function MedicationDetailScreen() {
|
||||
} finally {
|
||||
setAiAnalysisLoading(false);
|
||||
}
|
||||
}, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, medication, openMembershipModal, t]);
|
||||
}, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, isAiDraft, medication, openMembershipModal, t]);
|
||||
|
||||
const handleAiDraftSave = useCallback(async () => {
|
||||
if (!aiTaskId || !medication || aiDraftSaving) return;
|
||||
|
||||
try {
|
||||
setAiDraftSaving(true);
|
||||
const created = await confirmMedicationRecognition(aiTaskId, {
|
||||
name: medication.name,
|
||||
timesPerDay: medication.timesPerDay,
|
||||
medicationTimes: medication.medicationTimes,
|
||||
startDate: medication.startDate,
|
||||
endDate: medication.endDate ?? undefined,
|
||||
note: medication.note,
|
||||
});
|
||||
|
||||
await dispatch(fetchMedications());
|
||||
router.replace({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: { medicationId: created.id },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err);
|
||||
Alert.alert('保存失败', err?.message || '请稍后再试');
|
||||
} finally {
|
||||
setAiDraftSaving(false);
|
||||
}
|
||||
}, [aiDraftSaving, aiTaskId, dispatch, medication, router]);
|
||||
|
||||
if (!medicationId) {
|
||||
return (
|
||||
@@ -1109,7 +1297,7 @@ export default function MedicationDetailScreen() {
|
||||
<View style={styles.photoUploadingIndicator}>
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
|
||||
{t('medications.detail.photo.uploading', { defaultValue: '上传中...' })}
|
||||
上传中...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -1153,7 +1341,7 @@ export default function MedicationDetailScreen() {
|
||||
icon="calendar-outline"
|
||||
colors={colors}
|
||||
clickable={false}
|
||||
onPress={handleStartDatePress}
|
||||
onPress={isAiDraft ? undefined : handleStartDatePress}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('medications.detail.plan.time')}
|
||||
@@ -1161,23 +1349,27 @@ export default function MedicationDetailScreen() {
|
||||
icon="time-outline"
|
||||
colors={colors}
|
||||
clickable={false}
|
||||
onPress={handleTimePress}
|
||||
onPress={isAiDraft ? undefined : handleTimePress}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<InfoCard
|
||||
label={t('medications.detail.plan.expiryDate')}
|
||||
value={expiryDateLabel}
|
||||
icon="hourglass-outline"
|
||||
colors={colors}
|
||||
clickable
|
||||
onPress={handleExpiryDatePress}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('medications.detail.plan.frequency')}
|
||||
value={frequencyLabel}
|
||||
icon="repeat-outline"
|
||||
colors={colors}
|
||||
clickable={!isAiDraft}
|
||||
onPress={handleFrequencyPress}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.fullCard, { backgroundColor: colors.surface }]}
|
||||
onPress={handleFrequencyPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.fullCardLeading}>
|
||||
<Ionicons name="repeat-outline" size={18} color={colors.primary} />
|
||||
<Text style={[styles.fullCardLabel, { color: colors.text }]}>{t('medications.detail.plan.frequency')}</Text>
|
||||
</View>
|
||||
<View style={styles.fullCardTrailing}>
|
||||
<Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Section>
|
||||
|
||||
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
|
||||
@@ -1187,7 +1379,7 @@ export default function MedicationDetailScreen() {
|
||||
value={dosageLabel}
|
||||
icon="medkit-outline"
|
||||
colors={colors}
|
||||
clickable={true}
|
||||
clickable={!isAiDraft}
|
||||
onPress={handleDosagePress}
|
||||
/>
|
||||
<InfoCard
|
||||
@@ -1195,7 +1387,7 @@ export default function MedicationDetailScreen() {
|
||||
value={formLabel}
|
||||
icon="cube-outline"
|
||||
colors={colors}
|
||||
clickable={true}
|
||||
clickable={!isAiDraft}
|
||||
onPress={handleFormPress}
|
||||
/>
|
||||
</View>
|
||||
@@ -1427,68 +1619,92 @@ export default function MedicationDetailScreen() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.footerButtonContainer}>
|
||||
{/* AI 分析按钮 */}
|
||||
{!hasAiAnalysis && (
|
||||
{isAiDraft ? (
|
||||
<View style={styles.footerButtonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.aiAnalysisButtonWrapper}
|
||||
style={styles.secondaryFooterBtn}
|
||||
activeOpacity={0.9}
|
||||
onPress={handleAiAnalysis}
|
||||
disabled={aiAnalysisLoading}
|
||||
onPress={() => router.replace('/medications/ai-camera')}
|
||||
>
|
||||
<Text style={styles.secondaryFooterText}>重新拍摄</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryFooterBtn, { backgroundColor: colors.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={handleAiDraftSave}
|
||||
disabled={aiDraftSaving}
|
||||
>
|
||||
{aiDraftSaving ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}>保存并创建</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.footerButtonContainer}>
|
||||
{/* AI 分析按钮 */}
|
||||
{!hasAiAnalysis && (
|
||||
<TouchableOpacity
|
||||
style={styles.aiAnalysisButtonWrapper}
|
||||
activeOpacity={0.9}
|
||||
onPress={handleAiAnalysis}
|
||||
disabled={aiAnalysisLoading}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.aiAnalysisButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(59, 130, 246, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{aiAnalysisLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
||||
)}
|
||||
<Text style={styles.aiAnalysisButtonText}>
|
||||
{aiActionLabel}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.aiAnalysisButton, styles.fallbackAiButton]}>
|
||||
{aiAnalysisLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
||||
)}
|
||||
<Text style={styles.aiAnalysisButtonText}>
|
||||
{aiActionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButtonWrapper}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => setDeleteSheetVisible(true)}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.aiAnalysisButton}
|
||||
style={styles.deleteButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(59, 130, 246, 0.8)"
|
||||
tintColor="rgba(239, 68, 68, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{aiAnalysisLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
||||
)}
|
||||
<Text style={styles.aiAnalysisButtonText}>
|
||||
{aiActionLabel}
|
||||
</Text>
|
||||
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.aiAnalysisButton, styles.fallbackAiButton]}>
|
||||
{aiAnalysisLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
||||
)}
|
||||
<Text style={styles.aiAnalysisButtonText}>
|
||||
{aiActionLabel}
|
||||
</Text>
|
||||
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
|
||||
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButtonWrapper}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => setDeleteSheetVisible(true)}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.deleteButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(239, 68, 68, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
|
||||
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1510,7 +1726,7 @@ export default function MedicationDetailScreen() {
|
||||
<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} />
|
||||
@@ -1528,7 +1744,7 @@ export default function MedicationDetailScreen() {
|
||||
<TextInput
|
||||
value={nameDraft}
|
||||
onChangeText={handleNameChange}
|
||||
placeholder={t('medications.detail.nameEdit.placeholder', { defaultValue: '请输入药物名称' })}
|
||||
placeholder="请输入药物名称"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.nameInput, { color: colors.text }]}
|
||||
autoFocus
|
||||
@@ -1561,7 +1777,7 @@ export default function MedicationDetailScreen() {
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
|
||||
{t('medications.detail.nameEdit.saveButton', { defaultValue: '保存' })}
|
||||
保存
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -1790,9 +2006,19 @@ export default function MedicationDetailScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</Modal>
|
||||
|
||||
{medication ? (
|
||||
{/* 有效期选择器 */}
|
||||
<ExpiryDatePickerModal
|
||||
visible={expiryDatePickerVisible}
|
||||
currentDate={medication?.expiryDate ? new Date(medication.expiryDate) : null}
|
||||
onClose={() => setExpiryDatePickerVisible(false)}
|
||||
onConfirm={handleExpiryDateConfirm}
|
||||
isAiDraft={isAiDraft}
|
||||
/>
|
||||
|
||||
{medication && !isAiDraft ? (
|
||||
<ConfirmationSheet
|
||||
visible={deleteSheetVisible}
|
||||
onClose={() => setDeleteSheetVisible(false)}
|
||||
@@ -1806,7 +2032,7 @@ export default function MedicationDetailScreen() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{medication ? (
|
||||
{medication && !isAiDraft ? (
|
||||
<ConfirmationSheet
|
||||
visible={deactivateSheetVisible}
|
||||
onClose={() => setDeactivateSheetVisible(false)}
|
||||
@@ -2243,6 +2469,35 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
primaryFooterBtn: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
primaryFooterText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryFooterBtn: {
|
||||
height: 56,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryFooterText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
// AI 分析卡片样式
|
||||
aiCardContainer: {
|
||||
borderRadius: 26,
|
||||
|
||||
Reference in New Issue
Block a user