feat(fasting): 添加周期性断食计划功能
实现完整的周期性断食计划系统,支持每日自动续订和通知管理: - 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory) - 实现周期性断食会话的自动完成和续订逻辑 - 添加独立的周期性断食通知系统,避免与单次断食通知冲突 - 支持暂停/恢复周期性断食计划 - 添加周期性断食数据持久化和水合功能 - 优化断食界面,优先显示周期性断食信息 - 新增空状态引导界面,提升用户体验 - 保持单次断食功能向后兼容
This commit is contained in:
343
services/fastingCycleNotifications.ts
Normal file
343
services/fastingCycleNotifications.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { FastingPlan } from '@/constants/Fasting';
|
||||
import { FastingCycle, FastingCycleSession } from '@/store/fastingSlice';
|
||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { notificationService, NotificationTypes } from './notifications';
|
||||
|
||||
const REMINDER_OFFSET_MINUTES = 30; // 提前30分钟提醒
|
||||
|
||||
// 通知同步锁,防止并发操作
|
||||
let notificationSyncLock = false;
|
||||
|
||||
export class FastingCycleNotificationManager {
|
||||
/**
|
||||
* 为周期性断食计划安排通知
|
||||
* @param cycle 周期性断食计划
|
||||
* @param session 当前断食会话
|
||||
*/
|
||||
static async scheduleCycleNotifications(
|
||||
cycle: FastingCycle,
|
||||
session: FastingCycleSession | null
|
||||
): Promise<void> {
|
||||
// 获取锁,防止并发操作
|
||||
if (notificationSyncLock) {
|
||||
console.log('通知同步正在进行中,跳过本次操作');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
notificationSyncLock = true;
|
||||
|
||||
// 检查通知权限
|
||||
const notificationsEnabled = await getNotificationEnabled();
|
||||
console.log('🔔 周期性断食通知安排 - 权限检查:', {
|
||||
notificationsEnabled,
|
||||
cycleEnabled: cycle.enabled,
|
||||
planId: cycle.planId
|
||||
});
|
||||
|
||||
if (!notificationsEnabled) {
|
||||
console.log('⚠️ 用户已关闭通知权限,跳过通知安排');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cycle.enabled) {
|
||||
console.log('⚠️ 周期性断食已暂停,跳过通知安排');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有当前会话,不安排通知
|
||||
if (!session) {
|
||||
console.log('⚠️ 没有当前断食会话,跳过通知安排');
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = await this.getPlanById(cycle.planId);
|
||||
if (!plan) {
|
||||
console.warn('❌ 未找到断食计划:', cycle.planId);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const start = dayjs(session.startISO);
|
||||
const end = dayjs(session.endISO);
|
||||
|
||||
console.log('📅 断食会话信息:', {
|
||||
planTitle: plan.title,
|
||||
cycleDate: session.cycleDate,
|
||||
start: start.format('YYYY-MM-DD HH:mm'),
|
||||
end: end.format('YYYY-MM-DD HH:mm'),
|
||||
now: now.format('YYYY-MM-DD HH:mm'),
|
||||
});
|
||||
|
||||
// 如果断食期已结束,不安排通知
|
||||
if (end.isBefore(now)) {
|
||||
console.log('⚠️ 断食期已结束,跳过通知安排');
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消之前的周期性通知
|
||||
console.log('🧹 取消之前的周期性通知...');
|
||||
await this.cancelAllCycleNotifications();
|
||||
|
||||
// 1. 安排开始前30分钟通知
|
||||
if (start.isAfter(now)) {
|
||||
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
|
||||
if (preStart.isAfter(now)) {
|
||||
try {
|
||||
const notificationId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 即将开始`,
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'cycle_pre_start',
|
||||
cycleDate: session.cycleDate,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preStart.toDate()
|
||||
);
|
||||
console.log(`✅ 已安排周期性断食开始前30分钟通知 [${notificationId}]: ${preStart.format('YYYY-MM-DD HH:mm')}`);
|
||||
} catch (error) {
|
||||
console.error('❌ 安排周期性断食开始前30分钟通知失败', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ 跳过开始前30分钟通知(时间已过): ${preStart.format('YYYY-MM-DD HH:mm')}`);
|
||||
}
|
||||
|
||||
// 2. 安排开始时通知
|
||||
try {
|
||||
const notificationId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 开始了`,
|
||||
body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'cycle_start',
|
||||
cycleDate: session.cycleDate,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
start.toDate()
|
||||
);
|
||||
console.log(`✅ 已安排周期性断食开始时通知 [${notificationId}]: ${start.format('YYYY-MM-DD HH:mm')}`);
|
||||
} catch (error) {
|
||||
console.error('❌ 安排周期性断食开始时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 安排结束前30分钟通知
|
||||
if (end.isAfter(now)) {
|
||||
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
|
||||
if (preEnd.isAfter(now)) {
|
||||
try {
|
||||
const notificationId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: '即将结束断食',
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'cycle_pre_end',
|
||||
cycleDate: session.cycleDate,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preEnd.toDate()
|
||||
);
|
||||
console.log(`✅ 已安排周期性断食结束前30分钟通知 [${notificationId}]: ${preEnd.format('YYYY-MM-DD HH:mm')}`);
|
||||
} catch (error) {
|
||||
console.error('❌ 安排周期性断食结束前30分钟通知失败', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ 跳过结束前30分钟通知(时间已过): ${preEnd.format('YYYY-MM-DD HH:mm')}`);
|
||||
}
|
||||
|
||||
// 4. 安排结束时通知
|
||||
try {
|
||||
const notificationId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: '补能时刻到啦',
|
||||
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'cycle_end',
|
||||
cycleDate: session.cycleDate,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
end.toDate()
|
||||
);
|
||||
console.log(`✅ 已安排周期性断食结束时通知 [${notificationId}]: ${end.format('YYYY-MM-DD HH:mm')}`);
|
||||
} catch (error) {
|
||||
console.error('❌ 安排周期性断食结束时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 周期性断食通知安排完成');
|
||||
} catch (error) {
|
||||
console.error('❌ 安排周期性断食通知失败', error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
} finally {
|
||||
notificationSyncLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有周期性断食通知
|
||||
* 只取消带有 cycle_ 前缀的通知,避免影响单次断食通知
|
||||
*/
|
||||
static async cancelAllCycleNotifications(): Promise<void> {
|
||||
try {
|
||||
const scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
// 只过滤出周期性断食相关的通知(带有 cycle_ 前缀)
|
||||
const cycleNotifications = scheduledNotifications.filter(notification => {
|
||||
const data = notification.content.data as any;
|
||||
return data && data.subtype?.startsWith('cycle_');
|
||||
});
|
||||
|
||||
// 取消所有周期性通知
|
||||
for (const notification of cycleNotifications) {
|
||||
try {
|
||||
await notificationService.cancelNotification(notification.identifier);
|
||||
} catch (error) {
|
||||
console.warn('取消周期性断食通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`已取消 ${cycleNotifications.length} 个周期性断食通知`);
|
||||
} catch (error) {
|
||||
console.error('取消周期性断食通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新每日通知(在断食结束后安排下一天的通知)
|
||||
* @param cycle 周期性断食计划
|
||||
* @param nextSession 下一个断食会话
|
||||
*/
|
||||
static async updateDailyNotifications(
|
||||
cycle: FastingCycle,
|
||||
nextSession: FastingCycleSession | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 取消当前的通知
|
||||
await this.cancelAllCycleNotifications();
|
||||
|
||||
// 安排下一天的通知
|
||||
if (cycle.enabled && nextSession) {
|
||||
await this.scheduleCycleNotifications(cycle, nextSession);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新每日断食通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证周期性通知是否正确设置
|
||||
* @param cycle 周期性断食计划
|
||||
* @param session 当前断食会话
|
||||
*/
|
||||
static async verifyCycleNotifications(
|
||||
cycle: FastingCycle,
|
||||
session: FastingCycleSession | null
|
||||
): Promise<boolean> {
|
||||
// 如果正在同步,直接返回 true 避免重复操作
|
||||
if (notificationSyncLock) {
|
||||
console.log('通知同步正在进行中,跳过验证');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cycle.enabled || !session) {
|
||||
// 如果没有启用周期性计划或没有会话,应该没有通知
|
||||
const scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||
const cycleNotifications = scheduledNotifications.filter(notification => {
|
||||
const data = notification.content.data as any;
|
||||
return data && data.subtype?.startsWith('cycle_');
|
||||
});
|
||||
|
||||
if (cycleNotifications.length > 0) {
|
||||
// 有不应该存在的通知,需要清理
|
||||
await this.cancelAllCycleNotifications();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const start = dayjs(session.startISO);
|
||||
const end = dayjs(session.endISO);
|
||||
|
||||
// 如果断食期已结束,应该没有通知
|
||||
if (end.isBefore(now)) {
|
||||
await this.cancelAllCycleNotifications();
|
||||
return false;
|
||||
}
|
||||
|
||||
const scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||
const cycleNotifications = scheduledNotifications.filter(notification => {
|
||||
const data = notification.content.data as any;
|
||||
return data && data.subtype?.startsWith('cycle_');
|
||||
});
|
||||
|
||||
// 检查是否有足够的通知
|
||||
const expectedNotifications = [];
|
||||
|
||||
// 开始前30分钟通知
|
||||
if (start.isAfter(now)) {
|
||||
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
if (preStart.isAfter(now)) {
|
||||
expectedNotifications.push('cycle_pre_start');
|
||||
}
|
||||
expectedNotifications.push('cycle_start');
|
||||
}
|
||||
|
||||
// 结束前30分钟通知
|
||||
if (end.isAfter(now)) {
|
||||
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
if (preEnd.isAfter(now)) {
|
||||
expectedNotifications.push('cycle_pre_end');
|
||||
}
|
||||
expectedNotifications.push('cycle_end');
|
||||
}
|
||||
|
||||
// 如果通知数量不匹配,标记为需要重新安排,但不在这里直接调用
|
||||
if (cycleNotifications.length !== expectedNotifications.length) {
|
||||
console.log('周期性通知数量不匹配,需要重新安排');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('验证周期性断食通知失败', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取断食计划信息
|
||||
*/
|
||||
private static async getPlanById(planId: string): Promise<FastingPlan | undefined> {
|
||||
try {
|
||||
// 这里需要导入 FASTING_PLANS,但为了避免循环依赖,我们使用动态导入
|
||||
const { FASTING_PLANS } = await import('@/constants/Fasting');
|
||||
return FASTING_PLANS.find(plan => plan.id === planId);
|
||||
} catch (error) {
|
||||
console.error('获取断食计划失败', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user