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

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