feat(medications): 添加AI智能识别药品功能和有效期管理

- 新增AI药品识别流程,支持多角度拍摄和实时进度显示
- 添加药品有效期字段,支持在添加和编辑药品时设置有效期
- 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入)
- 新增ai-camera和ai-progress两个独立页面处理AI识别流程
- 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件
- 移除本地通知系统,迁移到服务端推送通知
- 添加medicationNotificationCleanup服务清理旧的本地通知
- 更新药品详情页支持AI草稿模式和有效期显示
- 优化药品表单,支持有效期选择和AI识别结果确认
- 更新i18n资源,添加有效期相关翻译

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
richarjiang
2025-11-21 17:32:44 +08:00
parent 29942feee9
commit bcb910140e
18 changed files with 2735 additions and 407 deletions

View File

@@ -1,14 +1,17 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { useVipService } from '@/hooks/useVipService';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { getItemSync, setItemSync } from '@/utils/kvStore';
@@ -46,6 +49,9 @@ export default function MedicationsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme];
const userProfile = useAppSelector((state) => state.user.profile);
const { ensureLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
@@ -53,34 +59,59 @@ export default function MedicationsScreen() {
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [addSheetVisible, setAddSheetVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
const handleOpenAddMedication = useCallback(() => {
// 检查是否已经读过免责声明
const handleOpenAddSheet = useCallback(() => {
setAddSheetVisible(true);
}, []);
const handleManualAdd = useCallback(() => {
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
setPendingAction('manual');
if (hasRead === 'true') {
// 已读过,直接跳转
setAddSheetVisible(false);
setPendingAction(null);
router.push('/medications/add-medication');
} else {
// 未读过,显示医疗免责声明弹窗
setAddSheetVisible(false);
setDisclaimerVisible(true);
}
}, []);
const handleAiRecognize = useCallback(async () => {
setAddSheetVisible(false);
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal();
return;
}
router.push('/medications/ai-camera');
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal, router]);
const handleDisclaimerConfirm = useCallback(() => {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
setDisclaimerVisible(false);
if (pendingAction === 'manual') {
setPendingAction(null);
router.push('/medications/add-medication');
}, []);
}
}, [pendingAction]);
const handleDisclaimerClose = useCallback(() => {
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
setDisclaimerVisible(false);
setPendingAction(null);
}, []);
const handleOpenMedicationManagement = useCallback(() => {
@@ -133,11 +164,8 @@ export default function MedicationsScreen() {
// 只获取一次药物数据,然后复用结果
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
// 并行执行获取药物记录和安排通知
const [recordsAction] = await Promise.all([
dispatch(fetchMedicationRecords({ date: selectedKey })),
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
]);
// 获取药物记录
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
// 同步数据到小组件(仅同步今天的)
const today = dayjs().format('YYYY-MM-DD');
@@ -274,7 +302,7 @@ export default function MedicationsScreen() {
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAddMedication}
onPress={handleOpenAddSheet}
>
{isLiquidGlassAvailable() ? (
<GlassView
@@ -391,6 +419,13 @@ export default function MedicationsScreen() {
)}
</ScrollView>
<MedicationAddOptionsSheet
visible={addSheetVisible}
onClose={() => setAddSheetVisible(false)}
onManualAdd={handleManualAdd}
onAiRecognize={handleAiRecognize}
/>
{/* 医疗免责声明弹窗 */}
<MedicalDisclaimerSheet
visible={disclaimerVisible}

View File

@@ -10,6 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions';
import { sleepMonitorService } from '@/services/sleepMonitor';
@@ -373,7 +374,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
await notificationService.initialize();
logger.info('✅ 通知服务初始化完成');
// 2. 异步同步 Widget 数据(不阻塞主流程
// 2. 清理旧的药品本地通知(迁移到服务端推送
cleanupLegacyMedicationNotifications().catch(error => {
logger.error('❌ 清理旧药品通知失败:', error);
});
// 3. 异步同步 Widget 数据(不阻塞主流程)
syncWidgetDataInBackground();
logger.info('🎉 权限相关服务初始化完成');
@@ -520,6 +526,8 @@ export default function RootLayout() {
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />

View File

@@ -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,
@@ -106,11 +116,46 @@ export default function MedicationDetailScreen() {
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);
@@ -185,6 +234,13 @@ export default function MedicationDetailScreen() {
deleteLoading
});
if (isAiDraft) {
return () => {
isMounted = false;
abortController.abort();
};
}
// 如果正在删除操作中,不执行任何操作
if (deleteLoading) {
console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect');
@@ -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(
(
@@ -879,6 +1040,15 @@ export default function MedicationDetailScreen() {
return;
}
if (isAiDraft) {
setMedication((prev) =>
prev
? { ...prev, dosageValue: dosageValuePicker, dosageUnit: dosageUnitPicker }
: prev
);
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
@@ -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;
@@ -915,6 +1078,11 @@ export default function MedicationDetailScreen() {
return;
}
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, form: formPicker } : prev));
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
@@ -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>
<TouchableOpacity
style={[styles.fullCard, { backgroundColor: colors.surface }]}
<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}
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,6 +1619,29 @@ export default function MedicationDetailScreen() {
},
]}
>
{isAiDraft ? (
<View style={styles.footerButtonContainer}>
<TouchableOpacity
style={styles.secondaryFooterBtn}
activeOpacity={0.9}
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 && (
@@ -1489,6 +1704,7 @@ export default function MedicationDetailScreen() {
)}
</TouchableOpacity>
</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,

View File

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

View 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',
},
});

View 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',
},
});

View File

@@ -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);

View File

@@ -0,0 +1,409 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
type Props = {
visible: boolean;
onClose: () => void;
onManualAdd: () => void;
onAiRecognize: () => void;
};
export function MedicationAddOptionsSheet({ visible, onClose, onManualAdd, onAiRecognize }: Props) {
const translateY = useRef(new Animated.Value(300)).current;
const opacity = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(false);
useEffect(() => {
if (visible) {
// 打开时:先显示 Modal然后执行动画
setModalVisible(true);
Animated.parallel([
Animated.spring(translateY, {
toValue: 0,
tension: 65,
friction: 11,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else if (modalVisible) {
// 关闭时:先执行动画,动画完成后隐藏 Modal
Animated.parallel([
Animated.timing(translateY, {
toValue: 300,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start(({ finished }) => {
if (finished) {
setModalVisible(false);
}
});
}
}, [visible, modalVisible, opacity, translateY]);
const handleClose = () => {
// 触发关闭动画
onClose();
};
return (
<Modal visible={modalVisible} transparent animationType="none" onRequestClose={handleClose}>
<Pressable style={styles.overlay} onPress={onClose}>
<Animated.View style={[styles.backdrop, { opacity }]} />
</Pressable>
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY }],
},
]}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}></Text>
</View>
<TouchableOpacity onPress={handleClose} style={styles.closeButton} activeOpacity={0.7}>
<Ionicons name="close" size={24} color="#64748b" />
</TouchableOpacity>
</View>
{/* AI 智能识别 - 主推荐 */}
<TouchableOpacity activeOpacity={0.95} onPress={onAiRecognize}>
<LinearGradient
colors={['#0ea5e9', '#0284c7']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.aiCard}
>
{/* 推荐标签 */}
<View style={styles.recommendBadge}>
<Ionicons name="sparkles" size={14} color="#fbbf24" />
<Text style={styles.recommendText}>使</Text>
</View>
<View style={styles.aiContent}>
<View style={styles.aiLeft}>
<View style={styles.aiIconWrapper}>
<Ionicons name="camera" size={32} color="#fff" />
</View>
<View style={styles.aiTexts}>
<Text style={styles.aiTitle}>AI </Text>
<Text style={styles.aiDescription}>
{'\n'}
</Text>
<View style={styles.aiFeatures}>
<View style={styles.featureItem}>
<Ionicons name="flash" size={14} color="#fff" />
<Text style={styles.featureText}></Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="checkmark-circle" size={14} color="#fff" />
<Text style={styles.featureText}></Text>
</View>
</View>
</View>
</View>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={styles.aiImage}
contentFit="contain"
/>
</View>
{/* AI 说明 */}
<View style={styles.aiFooter}>
<Ionicons name="information-circle-outline" size={14} color="rgba(255,255,255,0.8)" />
<Text style={styles.aiFooterText}> AI · 线</Text>
</View>
</LinearGradient>
</TouchableOpacity>
{/* 分隔线 */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}></Text>
<View style={styles.dividerLine} />
</View>
{/* 手动录入 - 次要选项 */}
<TouchableOpacity activeOpacity={0.9} onPress={onManualAdd}>
<View style={styles.manualCard}>
<View style={styles.manualLeft}>
<View style={styles.manualIconWrapper}>
<Ionicons name="create-outline" size={24} color="#6366f1" />
</View>
<View style={styles.manualTexts}>
<Text style={styles.manualTitle}></Text>
<Text style={styles.manualDescription}>
</Text>
</View>
</View>
<View style={styles.manualRight}>
<View style={styles.manualBadge}>
<Text style={styles.manualBadgeText}></Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#94a3b8" />
</View>
</View>
</TouchableOpacity>
{/* 底部安全距离 */}
<View style={styles.safeArea} />
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'transparent',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
paddingTop: 24,
paddingHorizontal: 20,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 20,
shadowOffset: { width: 0, height: -8 },
elevation: 12,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 24,
},
headerLeft: {
flex: 1,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#0f172a',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#64748b',
fontWeight: '500',
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#f1f5f9',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 12,
},
// AI 卡片 - 主推荐
aiCard: {
borderRadius: 24,
padding: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
recommendBadge: {
position: 'absolute',
top: 16,
right: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(255,255,255,0.25)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
recommendText: {
fontSize: 12,
fontWeight: '700',
color: '#fff',
},
aiContent: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
aiLeft: {
flex: 1,
flexDirection: 'row',
gap: 16,
},
aiIconWrapper: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.3)',
},
aiTexts: {
flex: 1,
gap: 8,
},
aiTitle: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
},
aiDescription: {
fontSize: 14,
color: 'rgba(255,255,255,0.9)',
lineHeight: 20,
},
aiFeatures: {
flexDirection: 'row',
gap: 12,
marginTop: 4,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
featureText: {
fontSize: 12,
fontWeight: '600',
color: '#fff',
},
aiImage: {
width: 80,
height: 80,
marginLeft: 12,
},
aiFooter: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.2)',
},
aiFooterText: {
fontSize: 12,
color: 'rgba(255,255,255,0.8)',
fontWeight: '500',
},
// 分隔线
divider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#e2e8f0',
},
dividerText: {
fontSize: 13,
color: '#94a3b8',
fontWeight: '600',
marginHorizontal: 16,
},
// 手动录入卡片 - 次要选项
manualCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f8fafc',
borderRadius: 20,
padding: 16,
borderWidth: 1.5,
borderColor: '#e2e8f0',
},
manualLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
manualIconWrapper: {
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: '#eef2ff',
alignItems: 'center',
justifyContent: 'center',
},
manualTexts: {
flex: 1,
gap: 4,
},
manualTitle: {
fontSize: 16,
fontWeight: '700',
color: '#0f172a',
},
manualDescription: {
fontSize: 13,
color: '#64748b',
lineHeight: 18,
},
manualRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginLeft: 12,
},
manualBadge: {
backgroundColor: '#dcfce7',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
manualBadgeText: {
fontSize: 11,
fontWeight: '700',
color: '#16a34a',
},
// 底部安全距离
safeArea: {
height: 32,
},
});

View File

@@ -0,0 +1,205 @@
import { ThemedText } from '@/components/ThemedText';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Modal, Platform, Pressable, StyleSheet, View } from 'react-native';
interface ExpiryDatePickerModalProps {
visible: boolean;
currentDate: Date | null;
onClose: () => void;
onConfirm: (date: Date) => void;
isAiDraft?: boolean;
}
/**
* 有效期日期选择器组件
*
* 功能:
* - 显示日期选择器弹窗
* - 验证日期不能早于今天
* - iOS 显示内联日历Android 显示原生对话框
* - 支持取消和确认操作
*/
export function ExpiryDatePickerModal({
visible,
currentDate,
onClose,
onConfirm,
isAiDraft = false,
}: ExpiryDatePickerModalProps) {
const { t } = useI18n();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
// 内部状态:选择的日期值
const [selectedDate, setSelectedDate] = useState<Date>(currentDate || new Date());
// 当弹窗显示时,同步当前日期
useEffect(() => {
if (visible) {
setSelectedDate(currentDate || new Date());
}
}, [visible, currentDate]);
/**
* 处理日期变化
* iOS: 实时更新选择的日期
* Android: 在用户点击确定时直接确认
*/
const handleDateChange = useCallback(
(event: any, date?: Date) => {
if (Platform.OS === 'ios') {
// iOS: 实时更新内部状态
if (date) {
setSelectedDate(date);
}
} else {
// Android: 处理用户操作
if (event.type === 'set' && date) {
// 用户点击确定
validateAndConfirm(date);
} else {
// 用户点击取消
onClose();
}
}
},
[onClose]
);
/**
* 验证并确认日期
*/
const validateAndConfirm = useCallback(
(dateToConfirm: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(dateToConfirm);
selected.setHours(0, 0, 0, 0);
if (selected < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
// 检查日期是否真的发生了变化
const currentExpiry = currentDate ? dayjs(currentDate).format('YYYY-MM-DD') : null;
const newExpiry = dayjs(dateToConfirm).format('YYYY-MM-DD');
if (currentExpiry === newExpiry) {
// 日期没有变化,直接关闭
onClose();
return;
}
// 日期有效且发生了变化,执行确认回调
onConfirm(dateToConfirm);
onClose();
},
[currentDate, onClose, onConfirm]
);
/**
* iOS 平台的确认按钮处理
*/
const handleIOSConfirm = useCallback(() => {
validateAndConfirm(selectedDate);
}, [selectedDate, validateAndConfirm]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.title, { color: colors.text }]}>
</ThemedText>
<DateTimePicker
value={selectedDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date()}
onChange={handleDateChange}
locale="zh-CN"
/>
{/* iOS 平台显示确认和取消按钮 */}
{Platform.OS === 'ios' && (
<View style={styles.actions}>
<Pressable
onPress={onClose}
style={[styles.btn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.btnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
onPress={handleIOSConfirm}
style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.btnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
sheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 20,
textAlign: 'center',
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
btn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
btnPrimary: {
borderWidth: 0,
},
btnText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,265 @@
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 React from 'react';
import {
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface MedicationPhotoGuideModalProps {
visible: boolean;
onClose: () => void;
}
/**
* 药品拍摄指南弹窗组件
* 展示如何正确拍摄药品照片的说明和示例
*/
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.guideModalContainer}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.guideModalContent}
>
{/* 标题部分 */}
<View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text>
<Text style={styles.guideTitle}></Text>
</View>
{/* 示例图片 */}
<View style={styles.guideImagesContainer}>
{/* 正确示例 */}
<View style={styles.guideImageWrapper}>
<View style={styles.guideImageBox}>
<Ionicons
name="checkmark-circle"
size={32}
color="#4CAF50"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={styles.guideImage}
contentFit="cover"
/>
</View>
<View style={styles.guideImageIndicator}>
<Ionicons name="checkmark-circle" size={20} color="#4CAF50" />
</View>
</View>
{/* 错误示例 */}
<View style={styles.guideImageWrapper}>
<View style={[styles.guideImageBox, styles.guideImageBoxBlur]}>
<Ionicons
name="close-circle"
size={32}
color="#F44336"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={[styles.guideImage, { opacity: 0.5 }]}
contentFit="cover"
blurRadius={8}
/>
</View>
<View style={[styles.guideImageIndicator, styles.guideImageIndicatorError]}>
<Ionicons name="close-circle" size={20} color="#F44336" />
</View>
</View>
</View>
{/* 说明文字 */}
<View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}>
\\
</Text>
<Text style={styles.guideDescriptionText}>
线
</Text>
</View>
{/* 确认按钮 */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.guideConfirmButton}
glassEffectStyle="regular"
tintColor="rgba(255, 179, 0, 0.9)"
isInteractive={true}
>
<LinearGradient
colors={['rgba(255, 179, 0, 0.95)', 'rgba(255, 160, 0, 0.95)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
</LinearGradient>
</GlassView>
) : (
<View style={styles.guideConfirmButton}>
<LinearGradient
colors={['#FFB300', '#FFA000']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
</LinearGradient>
</View>
)}
</TouchableOpacity>
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
guideModalContainer: {
width: SCREEN_WIDTH - 48,
maxHeight: '80%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 20,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
guideModalContent: {
padding: 24,
},
guideHeader: {
alignItems: 'center',
marginBottom: 24,
},
guideStepBadge: {
fontSize: 16,
fontWeight: '700',
color: '#FFB300',
marginBottom: 8,
},
guideTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
textAlign: 'center',
},
guideImagesContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
gap: 12,
},
guideImageWrapper: {
flex: 1,
alignItems: 'center',
},
guideImageBox: {
width: '100%',
aspectRatio: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#f8fafc',
position: 'relative',
borderWidth: 2,
borderColor: '#4CAF50',
},
guideImageBoxBlur: {
borderColor: '#F44336',
},
guideImage: {
width: '100%',
height: '100%',
},
guideImageIcon: {
position: 'absolute',
top: 8,
left: 8,
zIndex: 1,
},
guideImageIndicator: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
guideImageIndicatorError: {
backgroundColor: 'rgba(244, 67, 54, 0.1)',
},
guideDescription: {
backgroundColor: '#f8fafc',
borderRadius: 16,
padding: 16,
marginBottom: 24,
},
guideDescriptionText: {
fontSize: 14,
lineHeight: 22,
color: '#475569',
marginBottom: 8,
},
guideConfirmButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#FFB300',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
guideConfirmButtonGradient: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
guideConfirmButtonText: {
fontSize: 18,
fontWeight: '700',
color: '#FFFFFF',
},
});

View File

@@ -652,6 +652,7 @@ const medicationsResources = {
formLabels: {
capsule: '胶囊',
pill: '药片',
tablet: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
@@ -690,6 +691,7 @@ const medicationsResources = {
period: '服药周期',
time: '用药时间',
frequency: '频率',
expiryDate: '药品有效期',
longTerm: '长期',
periodMessage: '开始服药日期:{{startDate}}\n{{endDateInfo}}',
longTermPlan: '服药计划:长期服药',
@@ -1423,6 +1425,7 @@ const resources = {
formLabels: {
capsule: 'Capsule',
pill: 'Tablet',
tablet: 'Tablet',
injection: 'Injection',
spray: 'Spray',
drop: 'Drops',
@@ -1461,6 +1464,7 @@ const resources = {
period: 'Medication Period',
time: 'Medication Time',
frequency: 'Frequency',
expiryDate: 'Expiry Date',
longTerm: 'Long-term',
periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}',
longTermPlan: 'Medication plan: Long-term medication',

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.28</string>
<string>1.0.29</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -17,8 +17,8 @@
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
<rect key="frame" x="26" y="255.33333333333334" width="341.33333333333331" height="341.33333333333326"/>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
<rect key="frame" x="81" y="315" width="230" height="223"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
@@ -31,7 +31,7 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
<point key="canvasLocation" x="-0.76335877862595414" y="0.0"/>
</scene>
</scenes>
<resources>

View File

@@ -0,0 +1,66 @@
import { getItemSync, setItemSync } from '@/utils/kvStore';
import * as Notifications from 'expo-notifications';
const CLEANUP_KEY = 'medication_notifications_cleaned_v1';
/**
* 清理所有旧的药品本地通知
* 这个函数会在应用启动时执行一次,用于清理从本地通知迁移到服务端推送之前注册的所有药品通知
*/
export async function cleanupLegacyMedicationNotifications(): Promise<void> {
try {
// 检查是否已经执行过清理
const alreadyCleaned = getItemSync(CLEANUP_KEY);
if (alreadyCleaned === 'true') {
console.log('[药品通知清理] 已执行过清理,跳过');
return;
}
console.log('[药品通知清理] 开始清理旧的药品本地通知...');
// 获取所有已安排的通知
const scheduledNotifications = await Notifications.getAllScheduledNotificationsAsync();
if (scheduledNotifications.length === 0) {
console.log('[药品通知清理] 没有待清理的通知');
setItemSync(CLEANUP_KEY, 'true');
return;
}
console.log(`[药品通知清理] 发现 ${scheduledNotifications.length} 个已安排的通知,开始筛选药品通知...`);
// 筛选出药品相关的通知并取消
let cleanedCount = 0;
for (const notification of scheduledNotifications) {
const data = notification.content.data;
// 识别药品通知的特征:
// 1. data.type === 'medication_reminder'
// 2. data.medicationId 存在
// 3. identifier 包含 'medication' 关键字
const isMedicationNotification =
data?.type === 'medication_reminder' ||
data?.medicationId ||
notification.identifier?.includes('medication');
if (isMedicationNotification) {
try {
await Notifications.cancelScheduledNotificationAsync(notification.identifier);
cleanedCount++;
console.log(`[药品通知清理] 已取消通知: ${notification.identifier}`);
} catch (error) {
console.error(`[药品通知清理] 取消通知失败: ${notification.identifier}`, error);
}
}
}
console.log(`[药品通知清理] ✅ 清理完成,共取消 ${cleanedCount} 个药品通知`);
// 标记清理已完成
setItemSync(CLEANUP_KEY, 'true');
} catch (error) {
console.error('[药品通知清理] ❌ 清理过程出错:', error);
// 即使出错也标记为已清理,避免每次启动都尝试
setItemSync(CLEANUP_KEY, 'true');
}
}

View File

@@ -1,196 +0,0 @@
import type { Medication } from '@/types/medication';
import { getMedicationReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences';
import * as Notifications from 'expo-notifications';
import { notificationService, NotificationTypes } from './notifications';
/**
* 药品通知服务
* 负责管理药品提醒通知的调度和取消
*/
export class MedicationNotificationService {
private static instance: MedicationNotificationService;
private notificationPrefix = 'medication_';
private constructor() {}
public static getInstance(): MedicationNotificationService {
if (!MedicationNotificationService.instance) {
MedicationNotificationService.instance = new MedicationNotificationService();
}
return MedicationNotificationService.instance;
}
/**
* 检查是否可以发送药品通知
*/
private async canSendMedicationNotifications(): Promise<boolean> {
try {
// 检查总通知开关
const notificationEnabled = await getNotificationEnabled();
if (!notificationEnabled) {
console.log('总通知开关已关闭,跳过药品通知');
return false;
}
// 检查药品通知开关
const medicationReminderEnabled = await getMedicationReminderEnabled();
if (!medicationReminderEnabled) {
console.log('药品通知开关已关闭,跳过药品通知');
return false;
}
// 检查系统权限
const permissionStatus = await notificationService.getPermissionStatus();
if (permissionStatus !== 'granted') {
console.log('系统通知权限未授予,跳过药品通知');
return false;
}
return true;
} catch (error) {
console.error('检查药品通知权限失败:', error);
return false;
}
}
/**
* 为药品安排通知
*/
async scheduleMedicationNotifications(medication: Medication): Promise<void> {
try {
const canSend = await this.canSendMedicationNotifications();
if (!canSend) {
console.log('药品通知权限不足,跳过安排通知');
return;
}
// 先取消该药品的现有通知
await this.cancelMedicationNotifications(medication.id);
// 为每个用药时间安排通知
for (const time of medication.medicationTimes) {
const [hour, minute] = time.split(':').map(Number);
// 创建通知内容
const notificationContent = {
title: '用药提醒',
body: `该服用 ${medication.name} 了 (${medication.dosageValue}${medication.dosageUnit})`,
data: {
type: NotificationTypes.MEDICATION_REMINDER,
medicationId: medication.id,
medicationName: medication.name,
dosage: `${medication.dosageValue}${medication.dosageUnit}`,
},
sound: true,
priority: 'high' as const,
};
// 安排每日重复通知
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
notificationContent,
{
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
}
);
console.log(`已为药品 ${medication.name} 安排通知,时间: ${time}通知ID: ${notificationId}`);
}
} catch (error) {
console.error('安排药品通知失败:', error);
}
}
/**
* 取消药品的所有通知
*/
async cancelMedicationNotifications(medicationId: string): Promise<void> {
try {
// 获取所有已安排的通知
const allNotifications = await notificationService.getAllScheduledNotifications();
// 过滤出该药品的通知并取消
for (const notification of allNotifications) {
const data = notification.content.data as any;
if (data?.type === NotificationTypes.MEDICATION_REMINDER &&
data?.medicationId === medicationId) {
await notificationService.cancelNotification(notification.identifier);
console.log(`已取消药品通知ID: ${notification.identifier}`);
}
}
} catch (error) {
console.error('取消药品通知失败:', error);
}
}
/**
* 重新安排所有激活药品的通知
*/
async rescheduleAllMedicationNotifications(medications: Medication[]): Promise<void> {
try {
// 先取消所有药品通知
for (const medication of medications) {
await this.cancelMedicationNotifications(medication.id);
}
// 重新安排激活药品的通知
const activeMedications = medications.filter(m => m.isActive);
for (const medication of activeMedications) {
await this.scheduleMedicationNotifications(medication);
}
console.log(`已重新安排 ${activeMedications.length} 个激活药品的通知`);
} catch (error) {
console.error('重新安排药品通知失败:', error);
}
}
/**
* 发送立即的药品通知(用于测试)
*/
async sendTestMedicationNotification(medication: Medication): Promise<string> {
try {
const canSend = await this.canSendMedicationNotifications();
if (!canSend) {
throw new Error('药品通知权限不足');
}
return await notificationService.sendImmediateNotification({
title: '用药提醒测试',
body: `这是 ${medication.name} 的测试通知 (${medication.dosageValue}${medication.dosageUnit})`,
data: {
type: NotificationTypes.MEDICATION_REMINDER,
medicationId: medication.id,
medicationName: medication.name,
dosage: `${medication.dosageValue}${medication.dosageUnit}`,
},
sound: true,
priority: 'high',
});
} catch (error) {
console.error('发送测试药品通知失败:', error);
throw error;
}
}
/**
* 获取所有已安排的药品通知
*/
async getMedicationNotifications(): Promise<Notifications.NotificationRequest[]> {
try {
const allNotifications = await notificationService.getAllScheduledNotifications();
// 过滤出药品相关的通知
return allNotifications.filter(notification =>
notification.content.data?.type === NotificationTypes.MEDICATION_REMINDER
);
} catch (error) {
console.error('获取药品通知失败:', error);
return [];
}
}
}
// 导出单例实例
export const medicationNotificationService = MedicationNotificationService.getInstance();

View File

@@ -7,6 +7,7 @@ import type {
Medication,
MedicationAiAnalysisV2,
MedicationForm,
MedicationRecognitionTask,
MedicationRecord,
MedicationStatus,
RepeatPattern,
@@ -28,6 +29,7 @@ export interface CreateMedicationDto {
medicationTimes: string[];
startDate: string;
endDate?: string | null;
expiryDate?: string | null;
repeatPattern?: RepeatPattern;
note?: string;
}
@@ -344,3 +346,39 @@ export async function analyzeMedicationV2(
{}
);
}
// ==================== AI 药品识别任务 ====================
export interface CreateMedicationRecognitionDto {
frontImageUrl: string;
sideImageUrl: string;
auxiliaryImageUrl?: string;
}
export interface ConfirmMedicationRecognitionDto {
name?: string;
timesPerDay?: number;
medicationTimes?: string[];
startDate?: string;
endDate?: string | null;
note?: string;
}
export const createMedicationRecognitionTask = async (
dto: CreateMedicationRecognitionDto
): Promise<{ taskId: string; status: MedicationRecognitionTask['status'] }> => {
return api.post('/medications/ai-recognize', dto);
};
export const getMedicationRecognitionStatus = async (
taskId: string
): Promise<MedicationRecognitionTask> => {
return api.get(`/medications/ai-recognize/${taskId}/status`);
};
export const confirmMedicationRecognition = async (
taskId: string,
payload?: ConfirmMedicationRecognitionDto
): Promise<Medication> => {
return api.post(`/medications/ai-recognize/${taskId}/confirm`, payload ?? {});
};

View File

@@ -238,11 +238,6 @@ export class NotificationService {
console.log('用户点击了 HRV 压力通知', data);
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
router.push(targetUrl as any);
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
// 处理药品提醒通知
console.log('用户点击了药品提醒通知', data);
// 跳转到药品页面
router.push('/(tabs)/medications' as any);
}
}
@@ -584,7 +579,6 @@ export const NotificationTypes = {
WORKOUT_COMPLETION: 'workout_completion',
FASTING_START: 'fasting_start',
FASTING_END: 'fasting_end',
MEDICATION_REMINDER: 'medication_reminder',
HRV_STRESS_ALERT: 'hrv_stress_alert',
} as const;
@@ -623,21 +617,3 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
}
};
export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => {
const notification: NotificationData = {
title,
body,
data: {
type: NotificationTypes.MEDICATION_REMINDER,
medicationId: medicationId || ''
},
sound: true,
priority: 'high',
};
if (date) {
return notificationService.scheduleNotificationAtDate(notification, date);
} else {
return notificationService.sendImmediateNotification(notification);
}
};

View File

@@ -40,6 +40,7 @@ export interface Medication {
medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00']
startDate: string; // 开始日期 ISO
endDate?: string | null; // 结束日期 ISO可选
expiryDate?: string | null; // 药品有效期 ISO可选
repeatPattern: RepeatPattern; // 重复模式
note?: string; // 备注
aiAnalysis?: string; // AI 分析结果Markdown 格式)
@@ -105,3 +106,48 @@ export interface MedicationAiAnalysisV2 {
storageAdvice: string[]; // 储存建议
healthAdvice: string[]; // 健康建议/使用建议
}
/**
* AI 识别结果结构化数据
*/
export interface MedicationAiRecognitionResult {
name: string;
photoUrl?: string;
form?: MedicationForm;
dosageValue?: number;
dosageUnit?: string;
timesPerDay?: number;
medicationTimes?: string[];
startDate?: string;
endDate?: string | null;
expiryDate?: string | null;
note?: string;
suitableFor?: string[];
unsuitableFor?: string[];
mainIngredients?: string[];
mainUsage?: string;
sideEffects?: string[];
storageAdvice?: string[];
healthAdvice?: string[];
confidence?: number;
}
export type MedicationRecognitionStatus =
| 'pending'
| 'analyzing_product'
| 'analyzing_suitability'
| 'analyzing_ingredients'
| 'analyzing_effects'
| 'completed'
| 'failed';
export interface MedicationRecognitionTask {
taskId: string;
status: MedicationRecognitionStatus;
currentStep?: string;
progress?: number;
result?: MedicationAiRecognitionResult;
errorMessage?: string; // 识别失败时的错误信息
createdAt: string;
completedAt?: string;
}