feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet';
|
||||||
import { MedicationCard } from '@/components/medication/MedicationCard';
|
import { MedicationCard } from '@/components/medication/MedicationCard';
|
||||||
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
import { useVipService } from '@/hooks/useVipService';
|
||||||
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
||||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
import { getItemSync, setItemSync } from '@/utils/kvStore';
|
import { getItemSync, setItemSync } from '@/utils/kvStore';
|
||||||
@@ -46,6 +49,9 @@ export default function MedicationsScreen() {
|
|||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colors: ThemeColors = Colors[theme];
|
const colors: ThemeColors = Colors[theme];
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
const { checkServiceAccess } = useVipService();
|
||||||
|
const { openMembershipModal } = useMembershipModal();
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||||
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
||||||
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
||||||
@@ -53,34 +59,59 @@ export default function MedicationsScreen() {
|
|||||||
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
|
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
|
||||||
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
|
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
|
||||||
|
const [addSheetVisible, setAddSheetVisible] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
|
||||||
|
|
||||||
// 从 Redux 获取数据
|
// 从 Redux 获取数据
|
||||||
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||||
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
|
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);
|
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
|
||||||
|
setPendingAction('manual');
|
||||||
|
|
||||||
if (hasRead === 'true') {
|
if (hasRead === 'true') {
|
||||||
// 已读过,直接跳转
|
setAddSheetVisible(false);
|
||||||
|
setPendingAction(null);
|
||||||
router.push('/medications/add-medication');
|
router.push('/medications/add-medication');
|
||||||
} else {
|
} else {
|
||||||
// 未读过,显示医疗免责声明弹窗
|
setAddSheetVisible(false);
|
||||||
setDisclaimerVisible(true);
|
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(() => {
|
const handleDisclaimerConfirm = useCallback(() => {
|
||||||
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
|
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
|
||||||
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
|
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
|
||||||
setDisclaimerVisible(false);
|
setDisclaimerVisible(false);
|
||||||
router.push('/medications/add-medication');
|
if (pendingAction === 'manual') {
|
||||||
}, []);
|
setPendingAction(null);
|
||||||
|
router.push('/medications/add-medication');
|
||||||
|
}
|
||||||
|
}, [pendingAction]);
|
||||||
|
|
||||||
const handleDisclaimerClose = useCallback(() => {
|
const handleDisclaimerClose = useCallback(() => {
|
||||||
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
|
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
|
||||||
setDisclaimerVisible(false);
|
setDisclaimerVisible(false);
|
||||||
|
setPendingAction(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenMedicationManagement = useCallback(() => {
|
const handleOpenMedicationManagement = useCallback(() => {
|
||||||
@@ -133,11 +164,8 @@ export default function MedicationsScreen() {
|
|||||||
// 只获取一次药物数据,然后复用结果
|
// 只获取一次药物数据,然后复用结果
|
||||||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||||
|
|
||||||
// 并行执行获取药物记录和安排通知
|
// 获取药物记录
|
||||||
const [recordsAction] = await Promise.all([
|
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||||
dispatch(fetchMedicationRecords({ date: selectedKey })),
|
|
||||||
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 同步数据到小组件(仅同步今天的)
|
// 同步数据到小组件(仅同步今天的)
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
@@ -274,7 +302,7 @@ export default function MedicationsScreen() {
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={handleOpenAddMedication}
|
onPress={handleOpenAddSheet}
|
||||||
>
|
>
|
||||||
{isLiquidGlassAvailable() ? (
|
{isLiquidGlassAvailable() ? (
|
||||||
<GlassView
|
<GlassView
|
||||||
@@ -391,6 +419,13 @@ export default function MedicationsScreen() {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<MedicationAddOptionsSheet
|
||||||
|
visible={addSheetVisible}
|
||||||
|
onClose={() => setAddSheetVisible(false)}
|
||||||
|
onManualAdd={handleManualAdd}
|
||||||
|
onAiRecognize={handleAiRecognize}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 医疗免责声明弹窗 */}
|
{/* 医疗免责声明弹窗 */}
|
||||||
<MedicalDisclaimerSheet
|
<MedicalDisclaimerSheet
|
||||||
visible={disclaimerVisible}
|
visible={disclaimerVisible}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||||
import { hrvMonitorService } from '@/services/hrvMonitor';
|
import { hrvMonitorService } from '@/services/hrvMonitor';
|
||||||
|
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
|
||||||
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
||||||
import { setupQuickActions } from '@/services/quickActions';
|
import { setupQuickActions } from '@/services/quickActions';
|
||||||
import { sleepMonitorService } from '@/services/sleepMonitor';
|
import { sleepMonitorService } from '@/services/sleepMonitor';
|
||||||
@@ -373,7 +374,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
await notificationService.initialize();
|
await notificationService.initialize();
|
||||||
logger.info('✅ 通知服务初始化完成');
|
logger.info('✅ 通知服务初始化完成');
|
||||||
|
|
||||||
// 2. 异步同步 Widget 数据(不阻塞主流程)
|
// 2. 清理旧的药品本地通知(迁移到服务端推送)
|
||||||
|
cleanupLegacyMedicationNotifications().catch(error => {
|
||||||
|
logger.error('❌ 清理旧药品通知失败:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 异步同步 Widget 数据(不阻塞主流程)
|
||||||
syncWidgetDataInBackground();
|
syncWidgetDataInBackground();
|
||||||
|
|
||||||
logger.info('🎉 权限相关服务初始化完成');
|
logger.info('🎉 权限相关服务初始化完成');
|
||||||
@@ -520,6 +526,8 @@ export default function RootLayout() {
|
|||||||
name="health-data-permissions"
|
name="health-data-permissions"
|
||||||
options={{ headerShown: false }}
|
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="badges/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePickerModal';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
@@ -12,10 +13,11 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import { useVipService } from '@/hooks/useVipService';
|
import { useVipService } from '@/hooks/useVipService';
|
||||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
|
||||||
import {
|
import {
|
||||||
analyzeMedicationV2,
|
analyzeMedicationV2,
|
||||||
|
confirmMedicationRecognition,
|
||||||
getMedicationById,
|
getMedicationById,
|
||||||
|
getMedicationRecognitionStatus,
|
||||||
getMedicationRecords,
|
getMedicationRecords,
|
||||||
} from '@/services/medications';
|
} from '@/services/medications';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +26,12 @@ import {
|
|||||||
selectMedications,
|
selectMedications,
|
||||||
updateMedicationAction,
|
updateMedicationAction,
|
||||||
} from '@/store/medicationsSlice';
|
} 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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import Voice from '@react-native-voice/voice';
|
import Voice from '@react-native-voice/voice';
|
||||||
@@ -63,10 +70,12 @@ type RecordsSummary = {
|
|||||||
|
|
||||||
export default function MedicationDetailScreen() {
|
export default function MedicationDetailScreen() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const params = useLocalSearchParams<{ medicationId?: string }>();
|
const params = useLocalSearchParams<{ medicationId?: string; aiTaskId?: string; cover?: string }>();
|
||||||
const medicationId = Array.isArray(params.medicationId)
|
const medicationId = Array.isArray(params.medicationId)
|
||||||
? params.medicationId[0]
|
? params.medicationId[0]
|
||||||
: params.medicationId;
|
: 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 dispatch = useAppDispatch();
|
||||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||||
const colors = Colors[scheme];
|
const colors = Colors[scheme];
|
||||||
@@ -79,9 +88,10 @@ export default function MedicationDetailScreen() {
|
|||||||
|
|
||||||
const medications = useAppSelector(selectMedications);
|
const medications = useAppSelector(selectMedications);
|
||||||
const medicationFromStore = medications.find((item) => item.id === medicationId);
|
const medicationFromStore = medications.find((item) => item.id === medicationId);
|
||||||
|
const isAiDraft = Boolean(aiTaskId);
|
||||||
|
|
||||||
const [medication, setMedication] = useState<Medication | null>(medicationFromStore ?? null);
|
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>({
|
const [summary, setSummary] = useState<RecordsSummary>({
|
||||||
takenCount: 0,
|
takenCount: 0,
|
||||||
startedDays: null,
|
startedDays: null,
|
||||||
@@ -106,11 +116,46 @@ export default function MedicationDetailScreen() {
|
|||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
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 分析相关状态
|
// AI 分析相关状态
|
||||||
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
|
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
|
||||||
const [aiAnalysisResult, setAiAnalysisResult] = useState<MedicationAiAnalysisV2 | null>(null);
|
const [aiAnalysisResult, setAiAnalysisResult] = useState<MedicationAiAnalysisV2 | null>(null);
|
||||||
const [aiAnalysisError, setAiAnalysisError] = useState<string | null>(null);
|
const [aiAnalysisError, setAiAnalysisError] = useState<string | null>(null);
|
||||||
const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false);
|
const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false);
|
||||||
|
const [aiDraftSaving, setAiDraftSaving] = useState(false);
|
||||||
|
|
||||||
// 剂量选择相关状态
|
// 剂量选择相关状态
|
||||||
const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
|
const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
|
||||||
@@ -127,6 +172,10 @@ export default function MedicationDetailScreen() {
|
|||||||
medicationFromStore?.form ?? 'capsule'
|
medicationFromStore?.form ?? 'capsule'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 有效期选择相关状态
|
||||||
|
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
|
||||||
|
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
|
||||||
|
|
||||||
// ScrollView 引用,用于滚动到底部
|
// ScrollView 引用,用于滚动到底部
|
||||||
const scrollViewRef = React.useRef<ScrollView>(null);
|
const scrollViewRef = React.useRef<ScrollView>(null);
|
||||||
|
|
||||||
@@ -185,6 +234,13 @@ export default function MedicationDetailScreen() {
|
|||||||
deleteLoading
|
deleteLoading
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAiDraft) {
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 如果正在删除操作中,不执行任何操作
|
// 如果正在删除操作中,不执行任何操作
|
||||||
if (deleteLoading) {
|
if (deleteLoading) {
|
||||||
console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect');
|
console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect');
|
||||||
@@ -246,9 +302,57 @@ export default function MedicationDetailScreen() {
|
|||||||
};
|
};
|
||||||
}, [medicationId, medicationFromStore, deleteLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
if (!medicationId) {
|
if (!medicationId || isAiDraft) {
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
@@ -403,7 +507,7 @@ export default function MedicationDetailScreen() {
|
|||||||
}, [dictationActive]);
|
}, [dictationActive]);
|
||||||
|
|
||||||
const handleToggleMedication = async (nextValue: boolean) => {
|
const handleToggleMedication = async (nextValue: boolean) => {
|
||||||
if (!medication || updatePending) return;
|
if (!medication || updatePending || isAiDraft) return;
|
||||||
|
|
||||||
// 如果是关闭激活状态,显示确认弹窗
|
// 如果是关闭激活状态,显示确认弹窗
|
||||||
if (!nextValue) {
|
if (!nextValue) {
|
||||||
@@ -422,16 +526,6 @@ export default function MedicationDetailScreen() {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
// 重新安排药品通知
|
|
||||||
try {
|
|
||||||
if (nextValue) {
|
|
||||||
// 如果激活了药品,安排通知
|
|
||||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 处理药品通知失败:', error);
|
|
||||||
// 不影响药品状态切换的成功流程,只记录错误
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('切换药品状态失败', err);
|
console.error('切换药品状态失败', err);
|
||||||
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
|
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
|
||||||
@@ -441,7 +535,7 @@ export default function MedicationDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivateMedication = useCallback(async () => {
|
const handleDeactivateMedication = useCallback(async () => {
|
||||||
if (!medication || deactivateLoading) return;
|
if (!medication || deactivateLoading || isAiDraft) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setDeactivateLoading(true);
|
setDeactivateLoading(true);
|
||||||
@@ -455,13 +549,6 @@ export default function MedicationDetailScreen() {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
// 取消该药品的通知
|
|
||||||
try {
|
|
||||||
await medicationNotificationService.cancelMedicationNotifications(updated.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 取消药品通知失败:', error);
|
|
||||||
// 不影响药品状态切换的成功流程,只记录错误
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('停用药物失败', error);
|
console.error('停用药物失败', error);
|
||||||
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
|
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
|
||||||
@@ -492,6 +579,25 @@ export default function MedicationDetailScreen() {
|
|||||||
}
|
}
|
||||||
}, [medication, t]);
|
}, [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
|
const reminderTimes = medication?.medicationTimes?.length
|
||||||
? medication.medicationTimes.join('、')
|
? medication.medicationTimes.join('、')
|
||||||
: t('medications.manage.reminderNotSet');
|
: t('medications.manage.reminderNotSet');
|
||||||
@@ -539,18 +645,23 @@ export default function MedicationDetailScreen() {
|
|||||||
const trimmed = nameDraft.trim();
|
const trimmed = nameDraft.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
'提示',
|
||||||
t('medications.detail.nameEdit.errorEmpty', { defaultValue: '药物名称不能为空' })
|
'药物名称不能为空'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.from(trimmed).length > 10) {
|
if (Array.from(trimmed).length > 10) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
'提示',
|
||||||
t('medications.detail.nameEdit.errorTooLong', { defaultValue: '药物名称不能超过10个字' })
|
'药物名称不能超过10个字'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) => (prev ? { ...prev, name: trimmed } : prev));
|
||||||
|
setNameModalVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNameSaving(true);
|
setNameSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
@@ -564,17 +675,22 @@ export default function MedicationDetailScreen() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新药物名称失败', err);
|
console.error('更新药物名称失败', err);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }),
|
'提示',
|
||||||
t('medications.detail.nameEdit.saveError', { defaultValue: '名称更新失败,请稍后再试' })
|
'名称更新失败,请稍后再试'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setNameSaving(false);
|
setNameSaving(false);
|
||||||
}
|
}
|
||||||
}, [dispatch, medication, nameDraft, nameSaving, t]);
|
}, [dispatch, isAiDraft, medication, nameDraft, nameSaving, t]);
|
||||||
|
|
||||||
const handleSaveNote = useCallback(async () => {
|
const handleSaveNote = useCallback(async () => {
|
||||||
if (!medication) return;
|
if (!medication) return;
|
||||||
const trimmed = noteDraft.trim();
|
const trimmed = noteDraft.trim();
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) => (prev ? { ...prev, note: trimmed } : prev));
|
||||||
|
closeNoteModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNoteSaving(true);
|
setNoteSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
@@ -591,7 +707,7 @@ export default function MedicationDetailScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setNoteSaving(false);
|
setNoteSaving(false);
|
||||||
}
|
}
|
||||||
}, [closeNoteModal, dispatch, medication, noteDraft]);
|
}, [closeNoteModal, dispatch, isAiDraft, medication, noteDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serviceInfo.canUseService) {
|
if (serviceInfo.canUseService) {
|
||||||
@@ -620,14 +736,6 @@ export default function MedicationDetailScreen() {
|
|||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
||||||
|
|
||||||
// 先取消该药品的通知
|
|
||||||
try {
|
|
||||||
await medicationNotificationService.cancelMedicationNotifications(medication.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 取消药品通知失败:', error);
|
|
||||||
// 不影响药品删除的成功流程,只记录错误
|
|
||||||
}
|
|
||||||
|
|
||||||
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
||||||
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
||||||
router.back();
|
router.back();
|
||||||
@@ -650,11 +758,11 @@ export default function MedicationDetailScreen() {
|
|||||||
if (!medication || uploading) return;
|
if (!medication || uploading) return;
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('medications.detail.photo.selectTitle', { defaultValue: '选择图片' }),
|
t('medications.add.photo.selectTitle'),
|
||||||
t('medications.detail.photo.selectMessage', { defaultValue: '请选择图片来源' }),
|
t('medications.add.photo.selectMessage'),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: t('medications.detail.photo.takePhoto', { defaultValue: '拍照' }),
|
text: t('medications.add.photo.camera'),
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
@@ -689,6 +797,12 @@ export default function MedicationDetailScreen() {
|
|||||||
{ prefix: 'images/medications' }
|
{ prefix: 'images/medications' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
|
||||||
|
setPhotoPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 上传成功后更新药物信息
|
// 上传成功后更新药物信息
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
updateMedicationAction({
|
updateMedicationAction({
|
||||||
@@ -716,7 +830,7 @@ export default function MedicationDetailScreen() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t('medications.detail.photo.chooseFromLibrary', { defaultValue: '从相册选择' }),
|
text: t('medications.add.photo.album'),
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
@@ -750,6 +864,12 @@ export default function MedicationDetailScreen() {
|
|||||||
{ prefix: 'images/medications' }
|
{ prefix: 'images/medications' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
|
||||||
|
setPhotoPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 上传成功后更新药物信息
|
// 上传成功后更新药物信息
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
updateMedicationAction({
|
updateMedicationAction({
|
||||||
@@ -777,13 +897,13 @@ export default function MedicationDetailScreen() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t('medications.detail.photo.cancel', { defaultValue: '取消' }),
|
text: t('medications.add.photo.cancel'),
|
||||||
style: 'cancel',
|
style: 'cancel',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: true }
|
{ cancelable: true }
|
||||||
);
|
);
|
||||||
}, [medication, uploading, upload, dispatch, t]);
|
}, [dispatch, isAiDraft, medication, t, upload, uploading]);
|
||||||
|
|
||||||
const handleStartDatePress = useCallback(() => {
|
const handleStartDatePress = useCallback(() => {
|
||||||
if (!medication) return;
|
if (!medication) return;
|
||||||
@@ -792,22 +912,16 @@ export default function MedicationDetailScreen() {
|
|||||||
let message;
|
let message;
|
||||||
if (medication.endDate) {
|
if (medication.endDate) {
|
||||||
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
|
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
|
||||||
message = t('medications.detail.plan.periodMessage', {
|
message = `从 ${startDate} 至 ${endDate}`;
|
||||||
startDate,
|
|
||||||
endDateInfo: t('medications.detail.plan.periodMessage', { endDate })
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
message = t('medications.detail.plan.periodMessage', {
|
message = `从 ${startDate} 至长期`;
|
||||||
startDate,
|
|
||||||
endDateInfo: t('medications.detail.plan.longTermPlan')
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Alert.alert(t('medications.detail.sections.plan'), message);
|
Alert.alert('服药周期', message);
|
||||||
}, [medication, t]);
|
}, [medication, t]);
|
||||||
|
|
||||||
const handleTimePress = useCallback(() => {
|
const handleTimePress = useCallback(() => {
|
||||||
Alert.alert(t('medications.detail.plan.time'), t('medications.detail.plan.timeMessage', { times: reminderTimes }));
|
Alert.alert('服药时间', `每日提醒时间:${reminderTimes}`);
|
||||||
}, [reminderTimes, t]);
|
}, [reminderTimes, t]);
|
||||||
|
|
||||||
const handleDosagePress = useCallback(() => {
|
const handleDosagePress = useCallback(() => {
|
||||||
@@ -822,7 +936,18 @@ export default function MedicationDetailScreen() {
|
|||||||
|
|
||||||
const handleFrequencyPress = useCallback(() => {
|
const handleFrequencyPress = useCallback(() => {
|
||||||
if (!medication) return;
|
if (!medication) return;
|
||||||
// 跳转到独立的频率编辑页面
|
|
||||||
|
// AI 草稿模式:显示提示,暂不支持编辑频率
|
||||||
|
if (isAiDraft) {
|
||||||
|
Alert.alert(
|
||||||
|
'提示',
|
||||||
|
'请先保存药物信息后,再编辑服药频率',
|
||||||
|
[{ text: '知道了', style: 'default' }]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常模式:跳转到独立的频率编辑页面
|
||||||
router.push({
|
router.push({
|
||||||
pathname: ROUTES.MEDICATION_EDIT_FREQUENCY,
|
pathname: ROUTES.MEDICATION_EDIT_FREQUENCY,
|
||||||
params: {
|
params: {
|
||||||
@@ -833,7 +958,43 @@ export default function MedicationDetailScreen() {
|
|||||||
medicationTimes: medication.medicationTimes.join(','),
|
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(
|
const renderAdviceCard = useCallback(
|
||||||
(
|
(
|
||||||
@@ -879,6 +1040,15 @@ export default function MedicationDetailScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, dosageValue: dosageValuePicker, dosageUnit: dosageUnitPicker }
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUpdatePending(true);
|
setUpdatePending(true);
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
@@ -890,20 +1060,13 @@ export default function MedicationDetailScreen() {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
// 重新安排药品通知
|
|
||||||
try {
|
|
||||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
|
||||||
// 不影响药品更新的成功流程,只记录错误
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新剂量失败', err);
|
console.error('更新剂量失败', err);
|
||||||
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
|
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatePending(false);
|
setUpdatePending(false);
|
||||||
}
|
}
|
||||||
}, [dispatch, dosageUnitPicker, dosageValuePicker, medication, updatePending]);
|
}, [dispatch, dosageUnitPicker, dosageValuePicker, isAiDraft, medication, updatePending]);
|
||||||
|
|
||||||
const confirmFormPicker = useCallback(async () => {
|
const confirmFormPicker = useCallback(async () => {
|
||||||
if (!medication || updatePending) return;
|
if (!medication || updatePending) return;
|
||||||
@@ -915,6 +1078,11 @@ export default function MedicationDetailScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAiDraft) {
|
||||||
|
setMedication((prev) => (prev ? { ...prev, form: formPicker } : prev));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUpdatePending(true);
|
setUpdatePending(true);
|
||||||
const updated = await dispatch(
|
const updated = await dispatch(
|
||||||
@@ -925,24 +1093,17 @@ export default function MedicationDetailScreen() {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
// 重新安排药品通知
|
|
||||||
try {
|
|
||||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
|
||||||
// 不影响药品更新的成功流程,只记录错误
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新剂型失败', err);
|
console.error('更新剂型失败', err);
|
||||||
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
|
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatePending(false);
|
setUpdatePending(false);
|
||||||
}
|
}
|
||||||
}, [dispatch, formPicker, medication, updatePending]);
|
}, [dispatch, formPicker, isAiDraft, medication, updatePending]);
|
||||||
|
|
||||||
// AI 分析处理函数
|
// AI 分析处理函数
|
||||||
const handleAiAnalysis = useCallback(async () => {
|
const handleAiAnalysis = useCallback(async () => {
|
||||||
if (!medication || aiAnalysisLoading) return;
|
if (!medication || aiAnalysisLoading || isAiDraft) return;
|
||||||
|
|
||||||
// 1. 先验证用户是否登录
|
// 1. 先验证用户是否登录
|
||||||
const isLoggedIn = await ensureLoggedIn();
|
const isLoggedIn = await ensureLoggedIn();
|
||||||
@@ -1001,7 +1162,34 @@ export default function MedicationDetailScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAiAnalysisLoading(false);
|
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) {
|
if (!medicationId) {
|
||||||
return (
|
return (
|
||||||
@@ -1109,7 +1297,7 @@ export default function MedicationDetailScreen() {
|
|||||||
<View style={styles.photoUploadingIndicator}>
|
<View style={styles.photoUploadingIndicator}>
|
||||||
<ActivityIndicator color={colors.primary} size="small" />
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
|
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
|
||||||
{t('medications.detail.photo.uploading', { defaultValue: '上传中...' })}
|
上传中...
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1153,7 +1341,7 @@ export default function MedicationDetailScreen() {
|
|||||||
icon="calendar-outline"
|
icon="calendar-outline"
|
||||||
colors={colors}
|
colors={colors}
|
||||||
clickable={false}
|
clickable={false}
|
||||||
onPress={handleStartDatePress}
|
onPress={isAiDraft ? undefined : handleStartDatePress}
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
label={t('medications.detail.plan.time')}
|
label={t('medications.detail.plan.time')}
|
||||||
@@ -1161,23 +1349,27 @@ export default function MedicationDetailScreen() {
|
|||||||
icon="time-outline"
|
icon="time-outline"
|
||||||
colors={colors}
|
colors={colors}
|
||||||
clickable={false}
|
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>
|
</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>
|
||||||
|
|
||||||
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
|
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
|
||||||
@@ -1187,7 +1379,7 @@ export default function MedicationDetailScreen() {
|
|||||||
value={dosageLabel}
|
value={dosageLabel}
|
||||||
icon="medkit-outline"
|
icon="medkit-outline"
|
||||||
colors={colors}
|
colors={colors}
|
||||||
clickable={true}
|
clickable={!isAiDraft}
|
||||||
onPress={handleDosagePress}
|
onPress={handleDosagePress}
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
@@ -1195,7 +1387,7 @@ export default function MedicationDetailScreen() {
|
|||||||
value={formLabel}
|
value={formLabel}
|
||||||
icon="cube-outline"
|
icon="cube-outline"
|
||||||
colors={colors}
|
colors={colors}
|
||||||
clickable={true}
|
clickable={!isAiDraft}
|
||||||
onPress={handleFormPress}
|
onPress={handleFormPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -1427,68 +1619,92 @@ export default function MedicationDetailScreen() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.footerButtonContainer}>
|
{isAiDraft ? (
|
||||||
{/* AI 分析按钮 */}
|
<View style={styles.footerButtonContainer}>
|
||||||
{!hasAiAnalysis && (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.aiAnalysisButtonWrapper}
|
style={styles.secondaryFooterBtn}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
onPress={handleAiAnalysis}
|
onPress={() => router.replace('/medications/ai-camera')}
|
||||||
disabled={aiAnalysisLoading}
|
>
|
||||||
|
<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() ? (
|
{isLiquidGlassAvailable() ? (
|
||||||
<GlassView
|
<GlassView
|
||||||
style={styles.aiAnalysisButton}
|
style={styles.deleteButton}
|
||||||
glassEffectStyle="regular"
|
glassEffectStyle="regular"
|
||||||
tintColor="rgba(59, 130, 246, 0.8)"
|
tintColor="rgba(239, 68, 68, 0.8)"
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
>
|
>
|
||||||
{aiAnalysisLoading ? (
|
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
|
||||||
)}
|
|
||||||
<Text style={styles.aiAnalysisButtonText}>
|
|
||||||
{aiActionLabel}
|
|
||||||
</Text>
|
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.aiAnalysisButton, styles.fallbackAiButton]}>
|
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
|
||||||
{aiAnalysisLoading ? (
|
<Ionicons name="trash-outline" size={24} color="#fff" />
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="sparkles-outline" size={18} color="#fff" />
|
|
||||||
)}
|
|
||||||
<Text style={styles.aiAnalysisButtonText}>
|
|
||||||
{aiActionLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
</View>
|
||||||
|
)}
|
||||||
{/* 删除按钮 */}
|
|
||||||
<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}
|
) : null}
|
||||||
|
|
||||||
@@ -1510,7 +1726,7 @@ export default function MedicationDetailScreen() {
|
|||||||
<View style={styles.modalHandle} />
|
<View style={styles.modalHandle} />
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||||
{t('medications.detail.nameEdit.title', { defaultValue: '编辑药物名称' })}
|
编辑药物名称
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
|
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
|
||||||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||||
@@ -1528,7 +1744,7 @@ export default function MedicationDetailScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={nameDraft}
|
value={nameDraft}
|
||||||
onChangeText={handleNameChange}
|
onChangeText={handleNameChange}
|
||||||
placeholder={t('medications.detail.nameEdit.placeholder', { defaultValue: '请输入药物名称' })}
|
placeholder="请输入药物名称"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
style={[styles.nameInput, { color: colors.text }]}
|
style={[styles.nameInput, { color: colors.text }]}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -1561,7 +1777,7 @@ export default function MedicationDetailScreen() {
|
|||||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
|
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
|
||||||
{t('medications.detail.nameEdit.saveButton', { defaultValue: '保存' })}
|
保存
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -1790,9 +2006,19 @@ export default function MedicationDetailScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{medication ? (
|
{/* 有效期选择器 */}
|
||||||
|
<ExpiryDatePickerModal
|
||||||
|
visible={expiryDatePickerVisible}
|
||||||
|
currentDate={medication?.expiryDate ? new Date(medication.expiryDate) : null}
|
||||||
|
onClose={() => setExpiryDatePickerVisible(false)}
|
||||||
|
onConfirm={handleExpiryDateConfirm}
|
||||||
|
isAiDraft={isAiDraft}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{medication && !isAiDraft ? (
|
||||||
<ConfirmationSheet
|
<ConfirmationSheet
|
||||||
visible={deleteSheetVisible}
|
visible={deleteSheetVisible}
|
||||||
onClose={() => setDeleteSheetVisible(false)}
|
onClose={() => setDeleteSheetVisible(false)}
|
||||||
@@ -1806,7 +2032,7 @@ export default function MedicationDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{medication ? (
|
{medication && !isAiDraft ? (
|
||||||
<ConfirmationSheet
|
<ConfirmationSheet
|
||||||
visible={deactivateSheetVisible}
|
visible={deactivateSheetVisible}
|
||||||
onClose={() => setDeactivateSheetVisible(false)}
|
onClose={() => setDeactivateSheetVisible(false)}
|
||||||
@@ -2243,6 +2469,35 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#fff',
|
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 分析卡片样式
|
// AI 分析卡片样式
|
||||||
aiCardContainer: {
|
aiCardContainer: {
|
||||||
borderRadius: 26,
|
borderRadius: 26,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
|||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
|
||||||
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
||||||
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
||||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -151,10 +150,13 @@ export default function AddMedicationScreen() {
|
|||||||
const [timesPickerValue, setTimesPickerValue] = useState(1);
|
const [timesPickerValue, setTimesPickerValue] = useState(1);
|
||||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||||
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
|
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
|
||||||
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
|
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
|
||||||
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
|
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 [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
|
||||||
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
|
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
|
||||||
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
||||||
@@ -319,6 +321,7 @@ export default function AddMedicationScreen() {
|
|||||||
medicationTimes: medicationTimes,
|
medicationTimes: medicationTimes,
|
||||||
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
|
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
|
||||||
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
|
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
|
||||||
|
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
|
||||||
repeatPattern: 'daily' as RepeatPattern,
|
repeatPattern: 'daily' as RepeatPattern,
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
};
|
};
|
||||||
@@ -333,16 +336,6 @@ export default function AddMedicationScreen() {
|
|||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
await dispatch(fetchMedicationRecords({ date: today }));
|
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(
|
Alert.alert(
|
||||||
'添加成功',
|
'添加成功',
|
||||||
@@ -531,6 +524,11 @@ export default function AddMedicationScreen() {
|
|||||||
setEndDatePickerVisible(true);
|
setEndDatePickerVisible(true);
|
||||||
}, [endDate]);
|
}, [endDate]);
|
||||||
|
|
||||||
|
const openExpiryDatePicker = useCallback(() => {
|
||||||
|
setExpiryDatePickerValue(expiryDate || new Date());
|
||||||
|
setExpiryDatePickerVisible(true);
|
||||||
|
}, [expiryDate]);
|
||||||
|
|
||||||
const confirmStartDate = useCallback((date: Date) => {
|
const confirmStartDate = useCallback((date: Date) => {
|
||||||
// 验证开始日期不能早于今天
|
// 验证开始日期不能早于今天
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -563,6 +561,22 @@ export default function AddMedicationScreen() {
|
|||||||
setEndDatePickerVisible(false);
|
setEndDatePickerVisible(false);
|
||||||
}, [startDate]);
|
}, [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(
|
const openTimePicker = useCallback(
|
||||||
(index?: number) => {
|
(index?: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -872,6 +886,32 @@ export default function AddMedicationScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</View>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
@@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
visible={endDatePickerVisible}
|
visible={endDatePickerVisible}
|
||||||
transparent
|
transparent
|
||||||
|
|||||||
626
app/medications/ai-camera.tsx
Normal file
626
app/medications/ai-camera.tsx
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
|
||||||
|
const captureSteps = [
|
||||||
|
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||||
|
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||||
|
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type CaptureKey = (typeof captureSteps)[number]['key'];
|
||||||
|
|
||||||
|
type Shot = {
|
||||||
|
uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MedicationAiCameraScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||||
|
const colors = Colors[scheme];
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
const [facing, setFacing] = useState<'back' | 'front'>('back');
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
|
||||||
|
front: null,
|
||||||
|
side: null,
|
||||||
|
aux: null,
|
||||||
|
});
|
||||||
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
|
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||||
|
|
||||||
|
// 首次进入时显示引导弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage
|
||||||
|
if (!hasSeenGuide) {
|
||||||
|
setShowGuideModal(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentStep = captureSteps[currentStepIndex];
|
||||||
|
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
|
||||||
|
const allRequiredCaptured = Boolean(shots.front && shots.side);
|
||||||
|
|
||||||
|
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
|
||||||
|
|
||||||
|
const handleToggleCamera = () => {
|
||||||
|
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickFromAlbum = async () => {
|
||||||
|
try {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets?.length) {
|
||||||
|
const asset = result.assets[0];
|
||||||
|
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
|
||||||
|
|
||||||
|
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||||
|
if (currentStepIndex < captureSteps.length - 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
goNextStep();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION_AI] pick image failed', error);
|
||||||
|
Alert.alert('选择失败', '请重试或更换图片');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTakePicture = async () => {
|
||||||
|
if (!cameraRef.current) return;
|
||||||
|
try {
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
|
||||||
|
if (photo?.uri) {
|
||||||
|
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
|
||||||
|
|
||||||
|
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||||
|
if (currentStepIndex < captureSteps.length - 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
goNextStep();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION_AI] take picture failed', error);
|
||||||
|
Alert.alert('拍摄失败', '请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNextStep = () => {
|
||||||
|
if (currentStepIndex < captureSteps.length - 1) {
|
||||||
|
setCurrentStepIndex((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartRecognition = async () => {
|
||||||
|
// 检查必需照片是否完成
|
||||||
|
if (!allRequiredCaptured) {
|
||||||
|
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRecognition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecognition = async () => {
|
||||||
|
if (!shots.front || !shots.side) return;
|
||||||
|
const isLoggedIn = await ensureLoggedIn();
|
||||||
|
if (!isLoggedIn) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreatingTask(true);
|
||||||
|
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
|
||||||
|
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||||
|
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||||
|
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const task = await createMedicationRecognitionTask({
|
||||||
|
frontImageUrl: frontUpload.url,
|
||||||
|
sideImageUrl: sideUpload.url,
|
||||||
|
auxiliaryImageUrl: auxUpload?.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
pathname: '/medications/ai-progress',
|
||||||
|
params: {
|
||||||
|
taskId: task.taskId,
|
||||||
|
cover: frontUpload.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[MEDICATION_AI] recognize failed', error);
|
||||||
|
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
|
||||||
|
} finally {
|
||||||
|
setCreatingTask(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||||||
|
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
|
||||||
|
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||||||
|
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||||
|
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||||||
|
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||||
|
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 引导说明弹窗 - 移到最外层 */}
|
||||||
|
<MedicationPhotoGuideModal
|
||||||
|
visible={showGuideModal}
|
||||||
|
onClose={() => setShowGuideModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||||
|
<HeaderBar
|
||||||
|
title="AI 用药识别"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
transparent
|
||||||
|
right={
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowGuideModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityLabel="查看拍摄说明"
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.infoButton}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.3)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||||||
|
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={{ height: insets.top + 40 }} />
|
||||||
|
|
||||||
|
<View style={styles.topMeta}>
|
||||||
|
<View style={styles.metaBadge}>
|
||||||
|
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||||||
|
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cameraCard}>
|
||||||
|
<View style={styles.cameraFrame}>
|
||||||
|
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||||
|
style={styles.cameraOverlay}
|
||||||
|
/>
|
||||||
|
{coverPreview ? (
|
||||||
|
<View style={styles.previewBadge}>
|
||||||
|
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.shotsRow}>
|
||||||
|
{captureSteps.map((step, index) => {
|
||||||
|
const active = step.key === currentStep.key;
|
||||||
|
const shot = shots[step.key];
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={step.key}
|
||||||
|
onPress={() => setCurrentStepIndex(index)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||||
|
{step.title}
|
||||||
|
{!step.mandatory ? '(可选)' : ''}
|
||||||
|
</Text>
|
||||||
|
{shot ? (
|
||||||
|
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.shotPlaceholder}>
|
||||||
|
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||||
|
<View style={styles.bottomActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePickFromAlbum}
|
||||||
|
disabled={creatingTask || uploading}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.secondaryBtn}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.6)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||||
|
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||||
|
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||||
|
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleTakePicture}
|
||||||
|
disabled={creatingTask}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.captureBtn}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.8)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<View style={styles.captureOuterRing}>
|
||||||
|
<View style={styles.captureInner} />
|
||||||
|
</View>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||||||
|
<View style={styles.captureOuterRing}>
|
||||||
|
<View style={styles.captureInner} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggleCamera}
|
||||||
|
disabled={creatingTask}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.secondaryBtn}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.6)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||||
|
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||||
|
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||||
|
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 只要正面和背面都有照片就显示识别按钮 */}
|
||||||
|
{allRequiredCaptured && (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleStartRecognition}
|
||||||
|
disabled={creatingTask || uploading}
|
||||||
|
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
|
||||||
|
>
|
||||||
|
{creatingTask || uploading ? (
|
||||||
|
<ActivityIndicator color={colors.onPrimary} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
|
||||||
|
开始识别
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
topMeta: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
metaBadge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: '#e0f2fe',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
metaBadgeText: {
|
||||||
|
color: '#0369a1',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
metaTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
},
|
||||||
|
metaSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#475569',
|
||||||
|
},
|
||||||
|
cameraCard: {
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 12,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0f172a',
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
},
|
||||||
|
cameraFrame: {
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#0b172a',
|
||||||
|
height: 360,
|
||||||
|
},
|
||||||
|
cameraView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cameraOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
previewBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
previewImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
shotsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
shotCard: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
padding: 10,
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
shotCardActive: {
|
||||||
|
borderColor: '#38bdf8',
|
||||||
|
backgroundColor: '#ecfeff',
|
||||||
|
},
|
||||||
|
shotLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
shotLabelActive: {
|
||||||
|
color: '#0ea5e9',
|
||||||
|
},
|
||||||
|
shotThumb: {
|
||||||
|
width: '100%',
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
shotPlaceholder: {
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#e2e8f0',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
shotPlaceholderText: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
bottomBar: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
bottomActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
captureBtn: {
|
||||||
|
width: 86,
|
||||||
|
height: 86,
|
||||||
|
borderRadius: 43,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0ea5e9',
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 16,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
},
|
||||||
|
fallbackCaptureBtn: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||||
|
},
|
||||||
|
captureOuterRing: {
|
||||||
|
width: 76,
|
||||||
|
height: 76,
|
||||||
|
borderRadius: 38,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
captureInner: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
shadowColor: '#0ea5e9',
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0f172a',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
fallbackSecondaryBtn: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: '#0f172a',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
primaryCta: {
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#0f172a',
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
},
|
||||||
|
primaryText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
skipBtn: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
skipText: {
|
||||||
|
color: '#475569',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fallbackInfoButton: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
permissionCard: {
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
shadowColor: '#0f172a',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
},
|
||||||
|
permissionTip: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#475569',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
permissionBtn: {
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
permissionBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
514
app/medications/ai-progress.tsx
Normal file
514
app/medications/ai-progress.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||||
|
import { MedicationRecognitionTask } from '@/types/medication';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
|
||||||
|
{ key: 'analyzing_product', label: '正在进行产品分析...' },
|
||||||
|
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
|
||||||
|
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
|
||||||
|
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MedicationAiProgressScreen() {
|
||||||
|
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const navigatingRef = useRef(false);
|
||||||
|
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// 动画值:上下浮动和透明度
|
||||||
|
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
||||||
|
|
||||||
|
const currentStepIndex = useMemo(() => {
|
||||||
|
if (!task) return 0;
|
||||||
|
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
|
||||||
|
if (idx >= 0) return idx;
|
||||||
|
if (task.status === 'completed') return STATUS_STEPS.length;
|
||||||
|
return 0;
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (!taskId || navigatingRef.current) return;
|
||||||
|
try {
|
||||||
|
const data = await getMedicationRecognitionStatus(taskId as string);
|
||||||
|
setTask(data);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 识别成功,跳转到详情页
|
||||||
|
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
||||||
|
navigatingRef.current = true;
|
||||||
|
// 清除轮询
|
||||||
|
if (pollingTimerRef.current) {
|
||||||
|
clearInterval(pollingTimerRef.current);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
router.replace({
|
||||||
|
pathname: '/medications/[medicationId]',
|
||||||
|
params: {
|
||||||
|
medicationId: 'ai-draft',
|
||||||
|
aiTaskId: data.taskId,
|
||||||
|
cover: (cover as string) || data.result.photoUrl || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 识别失败,停止轮询并显示错误弹窗
|
||||||
|
if (data.status === 'failed' && !navigatingRef.current) {
|
||||||
|
navigatingRef.current = true;
|
||||||
|
// 清除轮询
|
||||||
|
if (pollingTimerRef.current) {
|
||||||
|
clearInterval(pollingTimerRef.current);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
// 显示错误提示弹窗
|
||||||
|
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
|
||||||
|
setShowErrorModal(true);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[MEDICATION_AI] status failed', err);
|
||||||
|
setError(err?.message || '查询失败,请稍后再试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理重新拍摄
|
||||||
|
const handleRetry = () => {
|
||||||
|
setShowErrorModal(false);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
||||||
|
return () => {
|
||||||
|
if (pollingTimerRef.current) {
|
||||||
|
clearInterval(pollingTimerRef.current);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
|
// 启动浮动和闪烁动画 - 更快的动画速度
|
||||||
|
useEffect(() => {
|
||||||
|
// 上下浮动动画 - 加快速度
|
||||||
|
const floatAnimation = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: -10,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 透明度闪烁动画 - 加快速度,增加对比度
|
||||||
|
const opacityAnimation = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(opacityAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 800,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacityAnim, {
|
||||||
|
toValue: 0.4,
|
||||||
|
duration: 800,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
floatAnimation.start();
|
||||||
|
opacityAnimation.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
floatAnimation.stop();
|
||||||
|
opacityAnimation.stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
|
||||||
|
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
|
||||||
|
<View style={{ height: insets.top }} />
|
||||||
|
|
||||||
|
<View style={styles.heroCard}>
|
||||||
|
<View style={styles.heroImageWrapper}>
|
||||||
|
{cover ? (
|
||||||
|
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.heroPlaceholder} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
||||||
|
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
||||||
|
<>
|
||||||
|
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
||||||
|
<View style={styles.overlayMask} />
|
||||||
|
|
||||||
|
{/* 渐变蒙版边框,增加视觉层次 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
|
||||||
|
style={styles.gradientBorder}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 点阵网格动画 */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.dottedGrid,
|
||||||
|
{
|
||||||
|
transform: [{ translateY: floatAnim }],
|
||||||
|
opacity: opacityAnim,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 11 }).map((_, idx) => (
|
||||||
|
<View key={idx} style={styles.dotRow}>
|
||||||
|
{Array.from({ length: 11 }).map((__, jdx) => (
|
||||||
|
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressRow}>
|
||||||
|
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.stepList}>
|
||||||
|
{STATUS_STEPS.map((step, index) => {
|
||||||
|
const active = index === currentStepIndex;
|
||||||
|
const done = index < currentStepIndex;
|
||||||
|
return (
|
||||||
|
<View key={step.key} style={styles.stepRow}>
|
||||||
|
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
||||||
|
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
||||||
|
{step.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{task?.status === 'completed' && (
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={[styles.bullet, styles.bulletDone]} />
|
||||||
|
<Text style={[styles.stepLabel, styles.stepLabelDone]}>识别完成,正在载入详情...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
||||||
|
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 识别提示弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showErrorModal}
|
||||||
|
transparent={true}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleRetry}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={handleRetry}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
style={styles.errorModalContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.errorModalContent}>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<Text style={styles.errorModalTitle}>需要重新拍摄</Text>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<View style={styles.errorMessageBox}>
|
||||||
|
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 重新拍摄按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRetry}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.retryButton}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(14, 165, 233, 0.9)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.retryButtonGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.retryButton}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#0ea5e9', '#06b6d4']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.retryButtonGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
heroCard: {
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 24,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#0f172a',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
},
|
||||||
|
heroImageWrapper: {
|
||||||
|
height: 230,
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
heroImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
heroPlaceholder: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
// 深色蒙版层,让点阵更清晰可见
|
||||||
|
overlayMask: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
||||||
|
},
|
||||||
|
// 渐变边框效果
|
||||||
|
gradientBorder: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 18,
|
||||||
|
},
|
||||||
|
// 点阵网格容器
|
||||||
|
dottedGrid: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
bottom: 16,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
dotRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
// 单个点样式 - 更明亮和更大的发光效果
|
||||||
|
dot: {
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#0ea5e9',
|
||||||
|
shadowOpacity: 0.9,
|
||||||
|
shadowRadius: 6,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
},
|
||||||
|
progressRow: {
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: '#f1f5f9',
|
||||||
|
borderRadius: 10,
|
||||||
|
marginTop: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#0ea5e9',
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
stepList: {
|
||||||
|
marginTop: 24,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
|
stepRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
bullet: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
bulletActive: {
|
||||||
|
backgroundColor: '#0ea5e9',
|
||||||
|
},
|
||||||
|
bulletDone: {
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
},
|
||||||
|
stepLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#94a3b8',
|
||||||
|
},
|
||||||
|
stepLabelActive: {
|
||||||
|
color: '#0f172a',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
stepLabelDone: {
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
marginTop: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
// Modal 样式
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||||
|
},
|
||||||
|
errorModalContainer: {
|
||||||
|
width: SCREEN_WIDTH - 48,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0ea5e9',
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 24,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
errorModalContent: {
|
||||||
|
padding: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorIconContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
errorIconCircle: {
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 48,
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.08)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
errorModalTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
errorMessageBox: {
|
||||||
|
backgroundColor: '#f0f9ff',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 28,
|
||||||
|
width: '100%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||||
|
},
|
||||||
|
errorMessageText: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 24,
|
||||||
|
color: '#475569',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0ea5e9',
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
retryButtonGradient: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,7 +4,6 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
|
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
|
||||||
import { updateMedicationAction } from '@/store/medicationsSlice';
|
import { updateMedicationAction } from '@/store/medicationsSlice';
|
||||||
import type { RepeatPattern } from '@/types/medication';
|
import type { RepeatPattern } from '@/types/medication';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -211,13 +210,6 @@ export default function EditMedicationFrequencyScreen() {
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// 重新安排药品通知
|
|
||||||
try {
|
|
||||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.back();
|
router.back();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新频率失败', err);
|
console.error('更新频率失败', err);
|
||||||
|
|||||||
409
components/medication/MedicationAddOptionsSheet.tsx
Normal file
409
components/medication/MedicationAddOptionsSheet.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
205
components/medications/ExpiryDatePickerModal.tsx
Normal file
205
components/medications/ExpiryDatePickerModal.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
265
components/medications/MedicationPhotoGuideModal.tsx
Normal file
265
components/medications/MedicationPhotoGuideModal.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -652,6 +652,7 @@ const medicationsResources = {
|
|||||||
formLabels: {
|
formLabels: {
|
||||||
capsule: '胶囊',
|
capsule: '胶囊',
|
||||||
pill: '药片',
|
pill: '药片',
|
||||||
|
tablet: '药片',
|
||||||
injection: '注射',
|
injection: '注射',
|
||||||
spray: '喷雾',
|
spray: '喷雾',
|
||||||
drop: '滴剂',
|
drop: '滴剂',
|
||||||
@@ -690,6 +691,7 @@ const medicationsResources = {
|
|||||||
period: '服药周期',
|
period: '服药周期',
|
||||||
time: '用药时间',
|
time: '用药时间',
|
||||||
frequency: '频率',
|
frequency: '频率',
|
||||||
|
expiryDate: '药品有效期',
|
||||||
longTerm: '长期',
|
longTerm: '长期',
|
||||||
periodMessage: '开始服药日期:{{startDate}}\n{{endDateInfo}}',
|
periodMessage: '开始服药日期:{{startDate}}\n{{endDateInfo}}',
|
||||||
longTermPlan: '服药计划:长期服药',
|
longTermPlan: '服药计划:长期服药',
|
||||||
@@ -1423,6 +1425,7 @@ const resources = {
|
|||||||
formLabels: {
|
formLabels: {
|
||||||
capsule: 'Capsule',
|
capsule: 'Capsule',
|
||||||
pill: 'Tablet',
|
pill: 'Tablet',
|
||||||
|
tablet: 'Tablet',
|
||||||
injection: 'Injection',
|
injection: 'Injection',
|
||||||
spray: 'Spray',
|
spray: 'Spray',
|
||||||
drop: 'Drops',
|
drop: 'Drops',
|
||||||
@@ -1461,6 +1464,7 @@ const resources = {
|
|||||||
period: 'Medication Period',
|
period: 'Medication Period',
|
||||||
time: 'Medication Time',
|
time: 'Medication Time',
|
||||||
frequency: 'Frequency',
|
frequency: 'Frequency',
|
||||||
|
expiryDate: 'Expiry Date',
|
||||||
longTerm: 'Long-term',
|
longTerm: 'Long-term',
|
||||||
periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}',
|
periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}',
|
||||||
longTermPlan: 'Medication plan: Long-term medication',
|
longTermPlan: 'Medication plan: Long-term medication',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.28</string>
|
<string>1.0.29</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
||||||
<rect key="frame" x="26" y="255.33333333333334" width="341.33333333333331" height="341.33333333333326"/>
|
<rect key="frame" x="81" y="315" width="230" height="223"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="0.0" y="0.0"/>
|
<point key="canvasLocation" x="-0.76335877862595414" y="0.0"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
|
|||||||
66
services/medicationNotificationCleanup.ts
Normal file
66
services/medicationNotificationCleanup.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Medication,
|
Medication,
|
||||||
MedicationAiAnalysisV2,
|
MedicationAiAnalysisV2,
|
||||||
MedicationForm,
|
MedicationForm,
|
||||||
|
MedicationRecognitionTask,
|
||||||
MedicationRecord,
|
MedicationRecord,
|
||||||
MedicationStatus,
|
MedicationStatus,
|
||||||
RepeatPattern,
|
RepeatPattern,
|
||||||
@@ -28,6 +29,7 @@ export interface CreateMedicationDto {
|
|||||||
medicationTimes: string[];
|
medicationTimes: string[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
|
expiryDate?: string | null;
|
||||||
repeatPattern?: RepeatPattern;
|
repeatPattern?: RepeatPattern;
|
||||||
note?: string;
|
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 ?? {});
|
||||||
|
};
|
||||||
|
|||||||
@@ -238,11 +238,6 @@ export class NotificationService {
|
|||||||
console.log('用户点击了 HRV 压力通知', data);
|
console.log('用户点击了 HRV 压力通知', data);
|
||||||
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
|
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
|
||||||
router.push(targetUrl as any);
|
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',
|
WORKOUT_COMPLETION: 'workout_completion',
|
||||||
FASTING_START: 'fasting_start',
|
FASTING_START: 'fasting_start',
|
||||||
FASTING_END: 'fasting_end',
|
FASTING_END: 'fasting_end',
|
||||||
MEDICATION_REMINDER: 'medication_reminder',
|
|
||||||
HRV_STRESS_ALERT: 'hrv_stress_alert',
|
HRV_STRESS_ALERT: 'hrv_stress_alert',
|
||||||
} as const;
|
} 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface Medication {
|
|||||||
medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00']
|
medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00']
|
||||||
startDate: string; // 开始日期 ISO
|
startDate: string; // 开始日期 ISO
|
||||||
endDate?: string | null; // 结束日期 ISO(可选)
|
endDate?: string | null; // 结束日期 ISO(可选)
|
||||||
|
expiryDate?: string | null; // 药品有效期 ISO(可选)
|
||||||
repeatPattern: RepeatPattern; // 重复模式
|
repeatPattern: RepeatPattern; // 重复模式
|
||||||
note?: string; // 备注
|
note?: string; // 备注
|
||||||
aiAnalysis?: string; // AI 分析结果(Markdown 格式)
|
aiAnalysis?: string; // AI 分析结果(Markdown 格式)
|
||||||
@@ -105,3 +106,48 @@ export interface MedicationAiAnalysisV2 {
|
|||||||
storageAdvice: string[]; // 储存建议
|
storageAdvice: string[]; // 储存建议
|
||||||
healthAdvice: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user