feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
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,
|
||||
MedicationAiAnalysisV2,
|
||||
MedicationForm,
|
||||
MedicationRecognitionTask,
|
||||
MedicationRecord,
|
||||
MedicationStatus,
|
||||
RepeatPattern,
|
||||
@@ -28,6 +29,7 @@ export interface CreateMedicationDto {
|
||||
medicationTimes: string[];
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
expiryDate?: string | null;
|
||||
repeatPattern?: RepeatPattern;
|
||||
note?: string;
|
||||
}
|
||||
@@ -344,3 +346,39 @@ export async function analyzeMedicationV2(
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== AI 药品识别任务 ====================
|
||||
|
||||
export interface CreateMedicationRecognitionDto {
|
||||
frontImageUrl: string;
|
||||
sideImageUrl: string;
|
||||
auxiliaryImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmMedicationRecognitionDto {
|
||||
name?: string;
|
||||
timesPerDay?: number;
|
||||
medicationTimes?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string | null;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const createMedicationRecognitionTask = async (
|
||||
dto: CreateMedicationRecognitionDto
|
||||
): Promise<{ taskId: string; status: MedicationRecognitionTask['status'] }> => {
|
||||
return api.post('/medications/ai-recognize', dto);
|
||||
};
|
||||
|
||||
export const getMedicationRecognitionStatus = async (
|
||||
taskId: string
|
||||
): Promise<MedicationRecognitionTask> => {
|
||||
return api.get(`/medications/ai-recognize/${taskId}/status`);
|
||||
};
|
||||
|
||||
export const confirmMedicationRecognition = async (
|
||||
taskId: string,
|
||||
payload?: ConfirmMedicationRecognitionDto
|
||||
): Promise<Medication> => {
|
||||
return api.post(`/medications/ai-recognize/${taskId}/confirm`, payload ?? {});
|
||||
};
|
||||
|
||||
@@ -238,11 +238,6 @@ export class NotificationService {
|
||||
console.log('用户点击了 HRV 压力通知', data);
|
||||
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
|
||||
router.push(targetUrl as any);
|
||||
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
|
||||
// 处理药品提醒通知
|
||||
console.log('用户点击了药品提醒通知', data);
|
||||
// 跳转到药品页面
|
||||
router.push('/(tabs)/medications' as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +579,6 @@ export const NotificationTypes = {
|
||||
WORKOUT_COMPLETION: 'workout_completion',
|
||||
FASTING_START: 'fasting_start',
|
||||
FASTING_END: 'fasting_end',
|
||||
MEDICATION_REMINDER: 'medication_reminder',
|
||||
HRV_STRESS_ALERT: 'hrv_stress_alert',
|
||||
} as const;
|
||||
|
||||
@@ -623,21 +617,3 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
|
||||
}
|
||||
};
|
||||
|
||||
export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => {
|
||||
const notification: NotificationData = {
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
type: NotificationTypes.MEDICATION_REMINDER,
|
||||
medicationId: medicationId || ''
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
};
|
||||
|
||||
if (date) {
|
||||
return notificationService.scheduleNotificationAtDate(notification, date);
|
||||
} else {
|
||||
return notificationService.sendImmediateNotification(notification);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user