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,