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,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
||||
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
@@ -151,10 +150,13 @@ export default function AddMedicationScreen() {
|
||||
const [timesPickerValue, setTimesPickerValue] = useState(1);
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
|
||||
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
|
||||
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
|
||||
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
|
||||
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
|
||||
const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
|
||||
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
|
||||
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
||||
@@ -319,6 +321,7 @@ export default function AddMedicationScreen() {
|
||||
medicationTimes: medicationTimes,
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
|
||||
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
|
||||
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
|
||||
repeatPattern: 'daily' as RepeatPattern,
|
||||
note: note.trim() || undefined,
|
||||
};
|
||||
@@ -333,16 +336,6 @@ export default function AddMedicationScreen() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
await dispatch(fetchMedicationRecords({ date: today }));
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
// 获取最新的药品列表
|
||||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
// 不影响添加药品的成功流程,只记录错误
|
||||
}
|
||||
|
||||
// 成功提示
|
||||
Alert.alert(
|
||||
'添加成功',
|
||||
@@ -531,6 +524,11 @@ export default function AddMedicationScreen() {
|
||||
setEndDatePickerVisible(true);
|
||||
}, [endDate]);
|
||||
|
||||
const openExpiryDatePicker = useCallback(() => {
|
||||
setExpiryDatePickerValue(expiryDate || new Date());
|
||||
setExpiryDatePickerVisible(true);
|
||||
}, [expiryDate]);
|
||||
|
||||
const confirmStartDate = useCallback((date: Date) => {
|
||||
// 验证开始日期不能早于今天
|
||||
const today = new Date();
|
||||
@@ -563,6 +561,22 @@ export default function AddMedicationScreen() {
|
||||
setEndDatePickerVisible(false);
|
||||
}, [startDate]);
|
||||
|
||||
const confirmExpiryDate = useCallback((date: Date) => {
|
||||
// 验证有效期不能早于今天
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const selectedDate = new Date(date);
|
||||
selectedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (selectedDate < today) {
|
||||
Alert.alert('日期无效', '有效期不能早于今天');
|
||||
return;
|
||||
}
|
||||
|
||||
setExpiryDate(date);
|
||||
setExpiryDatePickerVisible(false);
|
||||
}, []);
|
||||
|
||||
const openTimePicker = useCallback(
|
||||
(index?: number) => {
|
||||
try {
|
||||
@@ -872,6 +886,32 @@ export default function AddMedicationScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<View style={styles.periodHeader}>
|
||||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>药品有效期</ThemedText>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.dateRow,
|
||||
{
|
||||
borderColor: softBorderColor,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
onPress={openExpiryDatePicker}
|
||||
>
|
||||
<View style={styles.dateLeft}>
|
||||
<Ionicons name="time-outline" size={16} color={colors.textSecondary} />
|
||||
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}>有效期至</ThemedText>
|
||||
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
|
||||
{expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
case 3:
|
||||
@@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={expiryDatePickerVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setExpiryDatePickerVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.pickerBackdrop} onPress={() => setExpiryDatePickerVisible(false)} />
|
||||
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalTitle, { color: colors.text }]}>选择药品有效期</ThemedText>
|
||||
<DateTimePicker
|
||||
value={expiryDatePickerValue}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setExpiryDatePickerValue(date);
|
||||
} else {
|
||||
if (event.type === 'set' && date) {
|
||||
confirmExpiryDate(date);
|
||||
} else {
|
||||
setExpiryDatePickerVisible(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
onPress={() => setExpiryDatePickerVisible(false)}
|
||||
style={[styles.modalBtn, { borderColor: softBorderColor }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}>取消</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => confirmExpiryDate(expiryDatePickerValue)}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}>确定</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={endDatePickerVisible}
|
||||
transparent
|
||||
|
||||
626
app/medications/ai-camera.tsx
Normal file
626
app/medications/ai-camera.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
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 { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
const captureSteps = [
|
||||
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
|
||||
] as const;
|
||||
|
||||
type CaptureKey = (typeof captureSteps)[number]['key'];
|
||||
|
||||
type Shot = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export default function MedicationAiCameraScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [facing, setFacing] = useState<'back' | 'front'>('back');
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
|
||||
front: null,
|
||||
side: null,
|
||||
aux: null,
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage
|
||||
if (!hasSeenGuide) {
|
||||
setShowGuideModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const currentStep = captureSteps[currentStepIndex];
|
||||
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
|
||||
const allRequiredCaptured = Boolean(shots.front && shots.side);
|
||||
|
||||
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
|
||||
|
||||
const handleToggleCamera = () => {
|
||||
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const handlePickFromAlbum = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets?.length) {
|
||||
const asset = result.assets[0];
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] pick image failed', error);
|
||||
Alert.alert('选择失败', '请重试或更换图片');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakePicture = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
|
||||
if (photo?.uri) {
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] take picture failed', error);
|
||||
Alert.alert('拍摄失败', '请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const goNextStep = () => {
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecognition = async () => {
|
||||
// 检查必需照片是否完成
|
||||
if (!allRequiredCaptured) {
|
||||
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
|
||||
return;
|
||||
}
|
||||
|
||||
await startRecognition();
|
||||
};
|
||||
|
||||
const startRecognition = async () => {
|
||||
if (!shots.front || !shots.side) return;
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
|
||||
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const task = await createMedicationRecognitionTask({
|
||||
frontImageUrl: frontUpload.url,
|
||||
sideImageUrl: sideUpload.url,
|
||||
auxiliaryImageUrl: auxUpload?.url,
|
||||
});
|
||||
|
||||
router.replace({
|
||||
pathname: '/medications/ai-progress',
|
||||
params: {
|
||||
taskId: task.taskId,
|
||||
cover: frontUpload.url,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[MEDICATION_AI] recognize failed', error);
|
||||
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||||
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
|
||||
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||||
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 引导说明弹窗 - 移到最外层 */}
|
||||
<MedicationPhotoGuideModal
|
||||
visible={showGuideModal}
|
||||
onClose={() => setShowGuideModal(false)}
|
||||
/>
|
||||
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar
|
||||
title="AI 用药识别"
|
||||
onBack={() => router.back()}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowGuideModal(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="查看拍摄说明"
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.infoButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
<View style={{ height: insets.top + 40 }} />
|
||||
|
||||
<View style={styles.topMeta}>
|
||||
<View style={styles.metaBadge}>
|
||||
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||||
</View>
|
||||
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||||
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={styles.cameraFrame}>
|
||||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||
style={styles.cameraOverlay}
|
||||
/>
|
||||
{coverPreview ? (
|
||||
<View style={styles.previewBadge}>
|
||||
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.shotsRow}>
|
||||
{captureSteps.map((step, index) => {
|
||||
const active = step.key === currentStep.key;
|
||||
const shot = shots[step.key];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={step.key}
|
||||
onPress={() => setCurrentStepIndex(index)}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||
>
|
||||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||
{step.title}
|
||||
{!step.mandatory ? '(可选)' : ''}
|
||||
</Text>
|
||||
{shot ? (
|
||||
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.shotPlaceholder}>
|
||||
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePickFromAlbum}
|
||||
disabled={creatingTask || uploading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleTakePicture}
|
||||
disabled={creatingTask}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.captureBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleCamera}
|
||||
disabled={creatingTask}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 只要正面和背面都有照片就显示识别按钮 */}
|
||||
{allRequiredCaptured && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleStartRecognition}
|
||||
disabled={creatingTask || uploading}
|
||||
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
{creatingTask || uploading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
|
||||
开始识别
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
topMeta: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 6,
|
||||
},
|
||||
metaBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#e0f2fe',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
metaBadgeText: {
|
||||
color: '#0369a1',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
metaTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
metaSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
},
|
||||
cameraCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
cameraFrame: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0b172a',
|
||||
height: 360,
|
||||
},
|
||||
cameraView: {
|
||||
flex: 1,
|
||||
},
|
||||
cameraOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
previewBadge: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
shotsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
shotCard: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
shotCardActive: {
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: '#ecfeff',
|
||||
},
|
||||
shotLabel: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
shotLabelActive: {
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
shotThumb: {
|
||||
width: '100%',
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
},
|
||||
shotPlaceholder: {
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#e2e8f0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shotPlaceholderText: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 43,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
fallbackSecondaryBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
primaryCta: {
|
||||
marginTop: 6,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
primaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
skipBtn: {
|
||||
alignSelf: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
skipText: {
|
||||
color: '#475569',
|
||||
fontSize: 13,
|
||||
},
|
||||
infoButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackInfoButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
permissionCard: {
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
permissionTip: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
permissionBtn: {
|
||||
marginTop: 6,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
permissionBtnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
514
app/medications/ai-progress.tsx
Normal file
514
app/medications/ai-progress.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||
import { MedicationRecognitionTask } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
|
||||
{ key: 'analyzing_product', label: '正在进行产品分析...' },
|
||||
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
|
||||
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
|
||||
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
|
||||
];
|
||||
|
||||
export default function MedicationAiProgressScreen() {
|
||||
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const navigatingRef = useRef(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 动画值:上下浮动和透明度
|
||||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
const currentStepIndex = useMemo(() => {
|
||||
if (!task) return 0;
|
||||
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
|
||||
if (idx >= 0) return idx;
|
||||
if (task.status === 'completed') return STATUS_STEPS.length;
|
||||
return 0;
|
||||
}, [task]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!taskId || navigatingRef.current) return;
|
||||
try {
|
||||
const data = await getMedicationRecognitionStatus(taskId as string);
|
||||
setTask(data);
|
||||
setError(null);
|
||||
|
||||
// 识别成功,跳转到详情页
|
||||
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
router.replace({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: {
|
||||
medicationId: 'ai-draft',
|
||||
aiTaskId: data.taskId,
|
||||
cover: (cover as string) || data.result.photoUrl || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 识别失败,停止轮询并显示错误弹窗
|
||||
if (data.status === 'failed' && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
// 显示错误提示弹窗
|
||||
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[MEDICATION_AI] status failed', err);
|
||||
setError(err?.message || '查询失败,请稍后再试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重新拍摄
|
||||
const handleRetry = () => {
|
||||
setShowErrorModal(false);
|
||||
router.back();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
||||
return () => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// 启动浮动和闪烁动画 - 更快的动画速度
|
||||
useEffect(() => {
|
||||
// 上下浮动动画 - 加快速度
|
||||
const floatAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: -10,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// 透明度闪烁动画 - 加快速度,增加对比度
|
||||
const opacityAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0.4,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
floatAnimation.start();
|
||||
opacityAnimation.start();
|
||||
|
||||
return () => {
|
||||
floatAnimation.stop();
|
||||
opacityAnimation.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
|
||||
<View style={{ height: insets.top }} />
|
||||
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroImageWrapper}>
|
||||
{cover ? (
|
||||
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.heroPlaceholder} />
|
||||
)}
|
||||
|
||||
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
||||
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
||||
<>
|
||||
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
||||
<View style={styles.overlayMask} />
|
||||
|
||||
{/* 渐变蒙版边框,增加视觉层次 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
|
||||
style={styles.gradientBorder}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 点阵网格动画 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dottedGrid,
|
||||
{
|
||||
transform: [{ translateY: floatAnim }],
|
||||
opacity: opacityAnim,
|
||||
}
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: 11 }).map((_, idx) => (
|
||||
<View key={idx} style={styles.dotRow}>
|
||||
{Array.from({ length: 11 }).map((__, jdx) => (
|
||||
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.progressRow}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepList}>
|
||||
{STATUS_STEPS.map((step, index) => {
|
||||
const active = index === currentStepIndex;
|
||||
const done = index < currentStepIndex;
|
||||
return (
|
||||
<View key={step.key} style={styles.stepRow}>
|
||||
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
||||
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{task?.status === 'completed' && (
|
||||
<View style={styles.stepRow}>
|
||||
<View style={[styles.bullet, styles.bulletDone]} />
|
||||
<Text style={[styles.stepLabel, styles.stepLabelDone]}>识别完成,正在载入详情...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.loadingBox}>
|
||||
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
</View>
|
||||
|
||||
{/* 识别提示弹窗 */}
|
||||
<Modal
|
||||
visible={showErrorModal}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.errorModalContainer}
|
||||
>
|
||||
<View style={styles.errorModalContent}>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.errorModalTitle}>需要重新拍摄</Text>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<View style={styles.errorMessageBox}>
|
||||
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
||||
</View>
|
||||
|
||||
{/* 重新拍摄按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.retryButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(14, 165, 233, 0.9)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.retryButton}>
|
||||
<LinearGradient
|
||||
colors={['#0ea5e9', '#06b6d4']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
heroCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
heroImageWrapper: {
|
||||
height: 230,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
// 深色蒙版层,让点阵更清晰可见
|
||||
overlayMask: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
||||
},
|
||||
// 渐变边框效果
|
||||
gradientBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 18,
|
||||
},
|
||||
// 点阵网格容器
|
||||
dottedGrid: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
dotRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
// 单个点样式 - 更明亮和更大的发光效果
|
||||
dot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
},
|
||||
progressRow: {
|
||||
height: 8,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 10,
|
||||
marginTop: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
progressText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
textAlign: 'right',
|
||||
},
|
||||
stepList: {
|
||||
marginTop: 24,
|
||||
marginHorizontal: 24,
|
||||
gap: 14,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
bulletActive: {
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
bulletDone: {
|
||||
backgroundColor: '#22c55e',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepLabelDone: {
|
||||
color: '#16a34a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
loadingBox: {
|
||||
marginTop: 30,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
// Modal 样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
errorModalContainer: {
|
||||
width: SCREEN_WIDTH - 48,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
errorModalContent: {
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorIconContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorIconCircle: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorModalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorMessageBox: {
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 28,
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
errorMessageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
retryButtonGradient: {
|
||||
paddingVertical: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { Colors } from '@/constants/Colors';
|
||||
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
import { updateMedicationAction } from '@/store/medicationsSlice';
|
||||
import type { RepeatPattern } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -211,13 +210,6 @@ export default function EditMedicationFrequencyScreen() {
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
}
|
||||
|
||||
router.back();
|
||||
} catch (err) {
|
||||
console.error('更新频率失败', err);
|
||||
|
||||
Reference in New Issue
Block a user