diff --git a/README.md b/README.md index 4fbbdee..9115cce 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目 - Google Gemini 原生聊天请求 - 角色文件夹导入和角色切换 - 删除用户导入角色 -- 本地一次性提醒:聊天创建、查询、取消,重启后 pending 提醒不丢 -- 提醒到点气泡提示、拖动后延迟提示和隐藏时托盘通知 +- 本地一次性和重复提醒:聊天创建、查询、取消,重启后 pending 提醒不丢 +- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知 - 提醒音效切换、试听、用户 wav 导入和删除 - Windows 发布打包脚本和 Inno Setup 安装器脚本 - Windows GUI 子系统,Release exe 双击不弹控制台窗口 @@ -151,7 +151,7 @@ resources/characters/shiroko/ ## 定时提醒和音效 -当前支持通过聊天输入创建一次性本地提醒,例如: +当前支持通过聊天输入创建一次性和重复本地提醒,例如: ```text 10分钟后提醒我喝水 @@ -161,6 +161,14 @@ resources/characters/shiroko/ 后天9点提醒我开会 6月3日9点提醒我提交 下周一上午10点提醒我周会 +每天9点提醒我打卡 +每天提醒我9点打卡 +每日晚上8点提醒我吃药 +每周一上午10点提醒我周会 +每周一提醒我上午10点周会 +每星期五下午3点提醒我提交周报 +每月3号9点提醒我交报告 +每月3号提醒我9点交报告 提醒列表 取消喝水提醒 ``` @@ -171,6 +179,12 @@ resources/characters/shiroko/ QStandardPaths::AppConfigLocation/reminders.json ``` +提醒数据使用原子写入,写入失败时不会触发到点 UI,也不会覆盖旧的有效提醒文件。已触发和已取消记录会写入 `finishedAt`;旧版数据没有该字段时按 `remindAt` 兼容读取。 + +提醒调度保留最近提醒的精确 timer,同时每 60 秒做一次兜底扫描;程序显示、外部激活或系统睡眠唤醒后,都会重新检查已到期 pending 提醒。 + +设置页支持编辑 pending 提醒的标题、下一次时间和重复规则;已触发/已取消历史只读。历史记录默认只保留最近 20 天,设置页“清理20天前历史”只删除超过 20 天的已触发/已取消记录,不影响 pending。 + 提醒文件损坏时会备份为: ```text @@ -202,8 +216,15 @@ QStandardPaths::AppDataLocation/sounds/reminders/ - 桌宠可见时显示气泡,不发系统通知 - 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡 +- AI 正在请求或流式回复时,按隐藏场景处理:播放音效并发 Windows 托盘通知,不显示气泡 +- 托盘或系统通知后端不可用时只记录日志,不补气泡 - 用户拖动中不打断 `drag`,拖动结束后显示气泡 -- 重复提醒尚未支持,包含“每天 / 每周 / 每月”等语义时会提示暂不支持 +- 多条提醒同时触发时,可见状态下会按队列逐条展示 +- 桌宠可见触发时显示 `知道了` 和 `5分钟后再提醒` +- `5分钟后再提醒` 会创建一条新的一次性提醒,不影响原重复规则 +- 重复提醒支持 `每天 / 每周 / 每月`;`工作日 / 每两天 / 每月最后一天 / 自定义间隔 / 农历` 等复杂规则暂不支持 +- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份 +- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除 ## 配置和日志 diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md index 2e978f4..670fb7f 100644 --- a/docs/QtDesktopPet_后续功能规划与结构审查.md +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -30,7 +30,7 @@ - AI 请求取消和对话清空 - 角色文件夹导入和角色切换 - 删除用户导入角色 -- 本地一次性提醒、提醒列表、取消提醒和到点通知 +- 本地一次性/重复提醒、提醒列表、取消提醒和到点通知 - 内置/用户提醒音效切换、导入、删除和试听 - Windows 打包脚本和 Inno Setup 安装器脚本 - Release exe 双击不弹控制台窗口 @@ -366,7 +366,7 @@ CommandDispatcher::dispatch(userText) ## 5.1 功能定位 -实现本地一次性提醒功能。 +实现本地一次性和基础重复提醒功能。 用户可以输入: @@ -386,12 +386,19 @@ CommandDispatcher::dispatch(userText) 已新增 src/reminder/ 模块 已支持一次性提醒解析、JSON 持久化、启动后加载、到点触发和状态标记 已支持聊天创建 / 查询 / 取消提醒 -已支持设置页按状态查看提醒、取消 pending 提醒、清理已触发/已取消历史 +已支持设置页按状态查看提醒、取消 pending 提醒、编辑 pending 提醒、清理 20 天前已触发/已取消历史 已支持 reminder_default / reminder_soft 内置音效 已支持用户 wav 音效导入、删除、切换和试听 +已限制用户音效删除路径,只允许删除用户音效目录内的安全 sound id 提醒触发时使用当前设置页选择的全局音效,ReminderItem.soundId 仅保留为历史兼容字段 已接入 Qt Multimedia / QSoundEffect 播放提醒音效 已预留 NotificationDispatcher,当前 Windows 仍由托盘通知承接 +已支持每天 / 每周 / 每月重复提醒 +已支持提醒触发后的“知道了”和“5分钟后再提醒” +已支持提醒数据原子保存 +已支持多提醒可见队列,避免同时触发时互相覆盖 +已支持 60 秒兜底扫描,覆盖睡眠唤醒、系统时间变化和长间隔 timer 延迟场景 +通知后端不可用时会记录日志,不补气泡 ``` ## 5.2 第一版范围 @@ -400,6 +407,7 @@ CommandDispatcher::dispatch(userText) ```text 一次性提醒 +每天 / 每周 / 每月重复提醒 本地保存 程序重启后提醒不丢 到点后触发气泡和托盘通知 @@ -411,7 +419,8 @@ CommandDispatcher::dispatch(userText) 暂不做: ```text -重复提醒 +工作日提醒 +自定义间隔重复提醒 农历提醒 复杂日历 跨设备同步 @@ -449,7 +458,20 @@ struct ReminderItem QDateTime remindAt; ReminderStatus status = ReminderStatus::Pending; QDateTime createdAt; + QDateTime finishedAt; // triggered/canceled 记录的完成时间;旧数据缺失时按 remindAt 兼容 QString soundId; // 历史兼容字段,触发时不再读取 + ReminderRecurrence recurrence; +}; + +struct ReminderRecurrence +{ + ReminderRecurrenceType type = ReminderRecurrenceType::None; // None / Daily / Weekly / Monthly + int interval = 1; + int weekday = 0; // 1-7,仅 weekly 使用 + int monthDay = 0; // 1-31,仅 monthly 使用 + int hour = -1; + int minute = -1; + QDateTime lastTriggeredAt; }; ``` @@ -473,12 +495,26 @@ QStandardPaths::AppConfigLocation/reminders.json "remindAt": "2026-06-01T20:00:00", "status": "pending", "createdAt": "2026-06-01T15:20:00", - "soundId": "" + "soundId": "", + "recurrence": { + "type": "none", + "interval": 1, + "weekday": 0, + "monthDay": 0, + "hour": -1, + "minute": -1 + } } ] } ``` +旧版 `reminders.json` 没有 `recurrence` 字段时按一次性提醒读取,继续兼容;没有 `finishedAt` 字段时,已触发/已取消历史按 `remindAt` 作为保留时间兜底。 + +提醒数据保存使用原子写入。写入失败时创建 / 取消会回滚内存变更,到点触发会保留 pending 并等待下次重试,不播放音效、不通知、不气泡。 + +历史记录默认只保留最近 20 天。自动清理和设置页“清理20天前历史”只删除超过 20 天的 triggered/canceled 记录,不删除 pending。 + 配置损坏时备份: ```text @@ -511,9 +547,17 @@ reminders.broken.yyyyMMdd-HHmmss.json 1小时后 两小时后 2小时后 +每天9点 +每天提醒我9点打卡 +每日晚上8点 +每周一上午10点 +每周一提醒我上午10点周会 +每星期五下午3点 +每月3号9点 +每月3号提醒我9点交报告 ``` -如果规则解析失败,后续可以再接 AI 解析。包含“每天 / 每周 / 每月 / 工作日 / 重复”等语义时,当前只返回“重复提醒尚未支持”,不创建一次性提醒。 +如果规则解析失败,后续可以再接 AI 解析。当前重复提醒只支持每天 / 每周 / 每月。包含“工作日 / 每两天 / 每月最后一天 / 每季度 / 农历 / 自定义复杂规则”等语义时,返回明确暂不支持提示,不创建一次性提醒。 ## 5.7 AI 辅助解析的设计边界 @@ -558,7 +602,12 @@ userText ```text 桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知 桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡 +AI 正在请求或流式回复:按隐藏场景处理,播放当前全局音效并发 Windows 通知,不显示气泡,不进入补气泡队列 +Windows 托盘通知后端不可用:记录日志,不补气泡,不进入可见队列 用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知 +多条提醒同时触发:可见状态下进入队列逐条展示,避免后一条覆盖前一条 +桌宠可见触发时显示轻量操作区:“知道了”关闭当前提示,“5分钟后再提醒”固定创建新的 5 分钟一次性提醒 +重复提醒触发后先推进下一次 remindAt 并保存;稍后提醒不会影响原重复规则 ``` 提醒文案: @@ -1020,8 +1069,8 @@ CustomSearchProvider ## 9.2 第二步:定时提醒 ```text -当前已落地一次性提醒、提醒列表、取消提醒、到点气泡/托盘通知和提醒音效管理。 -后续可继续补确认/稍后提醒、重复提醒和跨平台通知实现。 +当前已落地一次性提醒、每天/每周/每月重复提醒、提醒列表、取消提醒、编辑 pending 提醒、20 天历史保留、到点气泡/托盘通知、稍后提醒和提醒音效管理。 +后续可继续补工作日/自定义间隔、跨平台通知实现和更复杂的提醒管理能力。 ``` ## 9.3 第三步:天气查询 diff --git a/main.cpp b/main.cpp index faee784..63b9c98 100644 --- a/main.cpp +++ b/main.cpp @@ -77,7 +77,7 @@ int main(int argc, char *argv[]) TrayController trayController(&window); window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable()); window.setTrayNotificationCallback([&trayController](const QString &title, const QString &message) { - trayController.showNotification(title, message); + return trayController.showNotification(title, message); }); trayController.show(); diff --git a/src/notification/NotificationDispatcher.cpp b/src/notification/NotificationDispatcher.cpp index ee84df3..fe8ac8e 100644 --- a/src/notification/NotificationDispatcher.cpp +++ b/src/notification/NotificationDispatcher.cpp @@ -7,10 +7,12 @@ void NotificationDispatcher::setShowCallback(ShowCallback callback) m_showCallback = std::move(callback); } -void NotificationDispatcher::showReminder(const QString &title, const QString &message) const +bool NotificationDispatcher::showReminder(const QString &title, const QString &message) const { if (m_showCallback) { - m_showCallback(title, message); + return m_showCallback(title, message); } + + return false; } diff --git a/src/notification/NotificationDispatcher.h b/src/notification/NotificationDispatcher.h index 962b57a..9f6d77d 100644 --- a/src/notification/NotificationDispatcher.h +++ b/src/notification/NotificationDispatcher.h @@ -7,10 +7,10 @@ class NotificationDispatcher { public: - using ShowCallback = std::function; + using ShowCallback = std::function; void setShowCallback(ShowCallback callback); - void showReminder(const QString &title, const QString &message) const; + bool showReminder(const QString &title, const QString &message) const; private: ShowCallback m_showCallback; diff --git a/src/reminder/ReminderCommandHandler.cpp b/src/reminder/ReminderCommandHandler.cpp index 839101d..e703407 100644 --- a/src/reminder/ReminderCommandHandler.cpp +++ b/src/reminder/ReminderCommandHandler.cpp @@ -11,11 +11,21 @@ ReminderCommandResult ReminderCommandHandler::handle(const QString &text, Remind { ReminderItem item; QString errorMessage; - if (!manager.createReminder(command.title, command.originalText, command.remindAt, &item, &errorMessage)) + if (!manager.createReminder(command.title, command.originalText, command.remindAt, command.recurrence, &item, &errorMessage)) { return {false, errorMessage.isEmpty() ? QStringLiteral("创建提醒失败。") : errorMessage, {}}; } + if (reminderIsRecurring(item)) + { + return { + true, + QStringLiteral("已设置重复提醒:%1,规则:%2,下一次:%3") + .arg(item.title, reminderRecurrenceDisplayText(item), reminderDisplayTime(item.remindAt)), + item, + }; + } + return { true, QStringLiteral("已设置提醒:%1,时间:%2").arg(item.title, reminderDisplayTime(item.remindAt)), diff --git a/src/reminder/ReminderManager.cpp b/src/reminder/ReminderManager.cpp index 8f995e0..76a24a9 100644 --- a/src/reminder/ReminderManager.cpp +++ b/src/reminder/ReminderManager.cpp @@ -2,9 +2,11 @@ #include "../util/Logger.h" +#include #include #include #include +#include #include #include @@ -13,12 +15,24 @@ namespace { constexpr qint64 MinimumTimerIntervalMs = 1000; constexpr qint64 MaximumTimerIntervalMs = 24 * 60 * 60 * 1000; +constexpr int GuardTimerIntervalMs = 60 * 1000; +constexpr int DefaultHistoryRetentionDays = 20; bool isPending(const ReminderItem &item) { return item.status == ReminderStatus::Pending; } +bool isFinished(const ReminderItem &item) +{ + return item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled; +} + +QDateTime finishedReferenceTime(const ReminderItem &item) +{ + return item.finishedAt.isValid() ? item.finishedAt : item.remindAt; +} + bool textMatchesReminder(const ReminderItem &item, const QString &query) { const QString normalizedQuery = query.trimmed(); @@ -31,6 +45,159 @@ bool textMatchesReminder(const ReminderItem &item, const QString &query) || item.title.contains(normalizedQuery, Qt::CaseInsensitive) || item.originalText.contains(normalizedQuery, Qt::CaseInsensitive); } + +QTime recurrenceTime(const ReminderItem &item) +{ + const int hour = item.recurrence.hour >= 0 ? item.recurrence.hour : item.remindAt.time().hour(); + const int minute = item.recurrence.minute >= 0 ? item.recurrence.minute : item.remindAt.time().minute(); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + { + return {}; + } + + return QTime(hour, minute); +} + +QDateTime nextDailyOccurrence(const ReminderItem &item, const QDateTime &after) +{ + const QTime time = recurrenceTime(item); + if (!time.isValid()) + { + return {}; + } + + const int interval = qMax(1, item.recurrence.interval); + QDateTime next(after.date(), time); + while (next <= after) + { + next = next.addDays(interval); + } + return next; +} + +QDateTime nextWeeklyOccurrence(const ReminderItem &item, const QDateTime &after) +{ + const QTime time = recurrenceTime(item); + if (!time.isValid() || item.recurrence.weekday < 1 || item.recurrence.weekday > 7) + { + return {}; + } + + const int intervalWeeks = qMax(1, item.recurrence.interval); + int daysToAdd = item.recurrence.weekday - after.date().dayOfWeek(); + QDateTime next(after.date().addDays(daysToAdd), time); + while (daysToAdd < 0 || next <= after) + { + next = next.addDays(7 * intervalWeeks); + daysToAdd += 7 * intervalWeeks; + } + return next; +} + +QDateTime nextMonthlyOccurrence(const ReminderItem &item, const QDateTime &after) +{ + const QTime time = recurrenceTime(item); + if (!time.isValid() || item.recurrence.monthDay < 1 || item.recurrence.monthDay > 31) + { + return {}; + } + + const int intervalMonths = qMax(1, item.recurrence.interval); + QDate monthCursor(after.date().year(), after.date().month(), 1); + for (int attempt = 0; attempt < 240; ++attempt) + { + const QDate date(monthCursor.year(), monthCursor.month(), item.recurrence.monthDay); + if (date.isValid()) + { + const QDateTime next(date, time); + if (next > after) + { + return next; + } + } + monthCursor = monthCursor.addMonths(intervalMonths); + } + + return {}; +} + +QDateTime nextRecurringOccurrence(const ReminderItem &item, const QDateTime &after) +{ + switch (item.recurrence.type) + { + case ReminderRecurrenceType::Daily: + return nextDailyOccurrence(item, after); + case ReminderRecurrenceType::Weekly: + return nextWeeklyOccurrence(item, after); + case ReminderRecurrenceType::Monthly: + return nextMonthlyOccurrence(item, after); + case ReminderRecurrenceType::None: + return {}; + } + + return {}; +} + +bool normalizeRecurrence(ReminderRecurrence *recurrence, QString *errorMessage) +{ + if (recurrence == nullptr || recurrence->type == ReminderRecurrenceType::None) + { + if (recurrence != nullptr) + { + *recurrence = {}; + } + return true; + } + + recurrence->interval = qMax(1, recurrence->interval); + if (recurrence->hour < 0 + || recurrence->hour > 23 + || recurrence->minute < 0 + || recurrence->minute > 59) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("重复提醒时间无效。"); + } + return false; + } + + switch (recurrence->type) + { + case ReminderRecurrenceType::Daily: + recurrence->weekday = 0; + recurrence->monthDay = 0; + return true; + case ReminderRecurrenceType::Weekly: + recurrence->monthDay = 0; + if (recurrence->weekday < 1 || recurrence->weekday > 7) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("每周提醒的星期无效。"); + } + return false; + } + return true; + case ReminderRecurrenceType::Monthly: + recurrence->weekday = 0; + if (recurrence->monthDay < 1 || recurrence->monthDay > 31) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("每月提醒的日期无效。"); + } + return false; + } + return true; + case ReminderRecurrenceType::None: + *recurrence = {}; + return true; + } + + *recurrence = {}; + return true; +} } ReminderManager::ReminderManager() @@ -38,19 +205,26 @@ ReminderManager::ReminderManager() QObject::connect(&m_timer, &QTimer::timeout, [this]() { processDueReminders(); }); + QObject::connect(&m_guardTimer, &QTimer::timeout, [this]() { + checkDueRemindersNow(); + }); m_timer.setSingleShot(true); load(); } void ReminderManager::start() { - if (m_started) + if (!m_started) { - return; + m_started = true; } - m_started = true; - processDueReminders(); + if (!m_guardTimer.isActive()) + { + m_guardTimer.start(GuardTimerIntervalMs); + } + + checkDueRemindersNow(); } void ReminderManager::setTriggeredCallback(TriggeredCallback callback) @@ -81,12 +255,38 @@ ReminderCommand ReminderManager::parseCommand(const QString &text) const return m_parser.parse(text); } +void ReminderManager::checkDueRemindersNow() +{ + if (!m_started) + { + return; + } + + processDueReminders(); + QString errorMessage; + if (!pruneFinishedReminders(DefaultHistoryRetentionDays, &errorMessage)) + { + Logger::warning(QStringLiteral("Failed to prune old reminder history: ") + errorMessage); + } +} + bool ReminderManager::createReminder( const QString &title, const QString &originalText, const QDateTime &remindAt, ReminderItem *createdItem, QString *errorMessage) +{ + return createReminder(title, originalText, remindAt, ReminderRecurrence{}, createdItem, errorMessage); +} + +bool ReminderManager::createReminder( + const QString &title, + const QString &originalText, + const QDateTime &remindAt, + const ReminderRecurrence &recurrence, + ReminderItem *createdItem, + QString *errorMessage) { if (!remindAt.isValid() || remindAt <= QDateTime::currentDateTime()) { @@ -97,6 +297,12 @@ bool ReminderManager::createReminder( return false; } + ReminderRecurrence normalizedRecurrence = recurrence; + if (!normalizeRecurrence(&normalizedRecurrence, errorMessage)) + { + return false; + } + ReminderItem item; item.id = nextReminderId(); item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed(); @@ -105,6 +311,11 @@ bool ReminderManager::createReminder( item.status = ReminderStatus::Pending; item.createdAt = QDateTime::currentDateTime(); item.soundId.clear(); + item.recurrence = normalizedRecurrence; + if (!reminderIsRecurring(item.recurrence)) + { + item.recurrence = {}; + } m_items.append(item); if (!save(errorMessage)) @@ -122,6 +333,33 @@ bool ReminderManager::createReminder( return true; } +bool ReminderManager::snoozeReminder( + const ReminderItem &sourceItem, + int minutes, + ReminderItem *createdItem, + QString *errorMessage) +{ + if (minutes <= 0) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("稍后提醒时间必须晚于当前时间。"); + } + return false; + } + + const QString originalText = sourceItem.originalText.trimmed().isEmpty() + ? sourceItem.title + : sourceItem.originalText; + return createReminder( + sourceItem.title, + originalText, + QDateTime::currentDateTime().addSecs(minutes * 60), + ReminderRecurrence{}, + createdItem, + errorMessage); +} + bool ReminderManager::cancelReminder(const QString &id, QString *errorMessage) { const QString normalizedId = id.trimmed(); @@ -129,11 +367,14 @@ bool ReminderManager::cancelReminder(const QString &id, QString *errorMessage) { if (item.id == normalizedId && isPending(item)) { + const QDateTime previousFinishedAt = item.finishedAt; item.status = ReminderStatus::Canceled; + item.finishedAt = QDateTime::currentDateTime(); const bool saved = save(errorMessage); if (!saved) { item.status = ReminderStatus::Pending; + item.finishedAt = previousFinishedAt; } scheduleNextReminder(); return saved; @@ -177,12 +418,15 @@ bool ReminderManager::cancelReminderByQuery(const QString &query, ReminderItem * } ReminderItem &item = m_items[matches.first()]; + const QDateTime previousFinishedAt = item.finishedAt; item.status = ReminderStatus::Canceled; + item.finishedAt = QDateTime::currentDateTime(); const bool saved = save(errorMessage); if (!saved) { item.status = ReminderStatus::Pending; + item.finishedAt = previousFinishedAt; } else if (canceledItem != nullptr) { @@ -193,12 +437,19 @@ bool ReminderManager::cancelReminderByQuery(const QString &query, ReminderItem * } bool ReminderManager::clearFinishedReminders(QString *errorMessage) +{ + return pruneFinishedReminders(DefaultHistoryRetentionDays, errorMessage); +} + +bool ReminderManager::pruneFinishedReminders(int retentionDays, QString *errorMessage) { QVector previousItems = m_items; + const int normalizedRetentionDays = qMax(0, retentionDays); + const QDateTime cutoff = QDateTime::currentDateTime().addDays(-normalizedRetentionDays); for (int index = m_items.size() - 1; index >= 0; --index) { - const ReminderStatus status = m_items.at(index).status; - if (status == ReminderStatus::Triggered || status == ReminderStatus::Canceled) + const ReminderItem &item = m_items.at(index); + if (isFinished(item) && finishedReferenceTime(item) < cutoff) { m_items.removeAt(index); } @@ -219,6 +470,78 @@ bool ReminderManager::clearFinishedReminders(QString *errorMessage) return true; } +bool ReminderManager::updateReminder( + const QString &id, + const QString &title, + const QDateTime &remindAt, + const ReminderRecurrence &recurrence, + QString *errorMessage) +{ + const QString normalizedId = id.trimmed(); + for (ReminderItem &item : m_items) + { + if (item.id != normalizedId || !isPending(item)) + { + continue; + } + + const ReminderItem previousItem = item; + const QDateTime now = QDateTime::currentDateTime(); + ReminderRecurrence normalizedRecurrence = recurrence; + if (!normalizeRecurrence(&normalizedRecurrence, errorMessage)) + { + return false; + } + + item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed(); + item.finishedAt = {}; + if (reminderIsRecurring(normalizedRecurrence)) + { + item.recurrence = normalizedRecurrence; + item.remindAt = nextRecurringOccurrence(item, now); + if (!item.remindAt.isValid()) + { + item = previousItem; + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("没有找到有效的重复提醒时间。"); + } + return false; + } + } + else + { + if (!remindAt.isValid() || remindAt <= now) + { + item = previousItem; + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提醒时间必须晚于当前时间。"); + } + return false; + } + + item.remindAt = remindAt; + item.recurrence = {}; + } + + if (!save(errorMessage)) + { + item = previousItem; + return false; + } + + scheduleNextReminder(); + return true; + } + + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("没有找到可编辑的待提醒事项。"); + } + return false; +} + QString ReminderManager::pendingReminderSummary() const { const QVector reminders = pendingReminders(); @@ -231,8 +554,8 @@ QString ReminderManager::pendingReminderSummary() const lines.append(QStringLiteral("当前待提醒:")); for (const ReminderItem &item : reminders) { - lines.append(QStringLiteral("%1:%2(%3)") - .arg(item.id, item.title, reminderDisplayTime(item.remindAt))); + lines.append(QStringLiteral("%1:%2(%3,下一次:%4)") + .arg(item.id, item.title, reminderRecurrenceDisplayText(item), reminderDisplayTime(item.remindAt))); } return lines.join(QChar('\n')); } @@ -256,15 +579,39 @@ void ReminderManager::processDueReminders() { const QDateTime now = QDateTime::currentDateTime(); QVector triggeredItems; - QVector triggeredIndexes; + const QVector previousItems = m_items; for (int index = 0; index < m_items.size(); ++index) { ReminderItem &item = m_items[index]; if (isPending(item) && item.remindAt <= now) { - item.status = ReminderStatus::Triggered; - triggeredItems.append(item); - triggeredIndexes.append(index); + ReminderItem triggeredItem = item; + triggeredItem.status = ReminderStatus::Triggered; + triggeredItem.finishedAt = now; + triggeredItem.recurrence.lastTriggeredAt = now; + if (reminderIsRecurring(item)) + { + const QDateTime nextOccurrence = nextRecurringOccurrence(item, now); + if (!nextOccurrence.isValid()) + { + Logger::warning(QStringLiteral("Failed to compute next recurring reminder occurrence: id=%1").arg(item.id)); + item.status = ReminderStatus::Triggered; + item.finishedAt = now; + } + else + { + item.recurrence.lastTriggeredAt = now; + item.remindAt = nextOccurrence; + item.finishedAt = {}; + } + } + else + { + item.status = ReminderStatus::Triggered; + item.finishedAt = now; + } + + triggeredItems.append(triggeredItem); } } @@ -274,13 +621,7 @@ void ReminderManager::processDueReminders() if (!save(&errorMessage)) { Logger::warning(QStringLiteral("Failed to save triggered reminders: ") + errorMessage); - for (const int index : triggeredIndexes) - { - if (index >= 0 && index < m_items.size()) - { - m_items[index].status = ReminderStatus::Pending; - } - } + m_items = previousItems; scheduleNextReminder(); return; } diff --git a/src/reminder/ReminderManager.h b/src/reminder/ReminderManager.h index f6eaa55..c9fe42e 100644 --- a/src/reminder/ReminderManager.h +++ b/src/reminder/ReminderManager.h @@ -21,6 +21,7 @@ public: QVector allReminders() const; QVector pendingReminders() const; ReminderCommand parseCommand(const QString &text) const; + void checkDueRemindersNow(); bool createReminder( const QString &title, @@ -28,9 +29,28 @@ public: const QDateTime &remindAt, ReminderItem *createdItem = nullptr, QString *errorMessage = nullptr); + bool createReminder( + const QString &title, + const QString &originalText, + const QDateTime &remindAt, + const ReminderRecurrence &recurrence, + ReminderItem *createdItem = nullptr, + QString *errorMessage = nullptr); + bool snoozeReminder( + const ReminderItem &sourceItem, + int minutes, + ReminderItem *createdItem = nullptr, + QString *errorMessage = nullptr); bool cancelReminder(const QString &id, QString *errorMessage = nullptr); bool cancelReminderByQuery(const QString &query, ReminderItem *canceledItem = nullptr, QString *errorMessage = nullptr); bool clearFinishedReminders(QString *errorMessage = nullptr); + bool pruneFinishedReminders(int retentionDays = 20, QString *errorMessage = nullptr); + bool updateReminder( + const QString &id, + const QString &title, + const QDateTime &remindAt, + const ReminderRecurrence &recurrence, + QString *errorMessage = nullptr); QString pendingReminderSummary() const; private: @@ -44,6 +64,7 @@ private: ReminderParser m_parser; QVector m_items; QTimer m_timer; + QTimer m_guardTimer; TriggeredCallback m_triggeredCallback; bool m_started = false; }; diff --git a/src/reminder/ReminderParser.cpp b/src/reminder/ReminderParser.cpp index f505ddb..f71aef1 100644 --- a/src/reminder/ReminderParser.cpp +++ b/src/reminder/ReminderParser.cpp @@ -182,6 +182,13 @@ struct ReminderDateResolution bool explicitDate = false; }; +struct ParsedReminderTime +{ + int hour = -1; + int minute = -1; + QString expression; +}; + ReminderDateResolution resolveReminderDate(const QString &text, const QDate ¤tDate) { QRegularExpressionMatch match; @@ -254,6 +261,134 @@ QString removeFirst(const QString &text, const QString &part) } return result; } + +ReminderCommand invalidRecurringCommand(const QString &text, const QString &message) +{ + return {ReminderCommandType::Invalid, {}, text, {}, {}, message}; +} + +ReminderCommand createReminderCommand( + const QString &title, + const QString &originalText, + const QDateTime &remindAt, + const ReminderRecurrence &recurrence = {}) +{ + ReminderCommand command; + command.type = ReminderCommandType::Create; + command.title = title; + command.originalText = originalText; + command.remindAt = remindAt; + command.recurrence = recurrence; + return command; +} + +ParsedReminderTime parsedTimeFromPointMatch(const QRegularExpressionMatch &match, int periodIndex, int hourIndex, int minuteIndex) +{ + const QString period = match.captured(periodIndex); + const int hour = adjustedHour(period, parseSmallInteger(match.captured(hourIndex))); + const int minute = match.captured(minuteIndex).isEmpty() ? 0 : match.captured(minuteIndex).toInt(); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + { + return {}; + } + + return {hour, minute, match.captured(0)}; +} + +ParsedReminderTime parsedTimeFromColonMatch(const QRegularExpressionMatch &match, int hourIndex, int minuteIndex) +{ + const int hour = match.captured(hourIndex).toInt(); + const int minute = match.captured(minuteIndex).toInt(); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + { + return {}; + } + + return {hour, minute, match.captured(0)}; +} + +ParsedReminderTime parseTimeExpression(const QString &text) +{ + QRegularExpressionMatch match; + const QRegularExpression pointExpression(QStringLiteral("(上午|早上|下午|晚上|中午|凌晨)?\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*点\\s*(?:(\\d{1,2})\\s*分?)?")); + match = pointExpression.match(text); + if (match.hasMatch()) + { + return parsedTimeFromPointMatch(match, 1, 2, 3); + } + + const QRegularExpression colonExpression(QStringLiteral("([01]?\\d|2[0-3])\\s*[::]\\s*([0-5]\\d)")); + match = colonExpression.match(text); + if (match.hasMatch()) + { + return parsedTimeFromColonMatch(match, 1, 2); + } + + return {}; +} + +QDateTime nextDailyOccurrence(const QDateTime &now, const QTime &time) +{ + QDateTime next(now.date(), time); + if (next <= now) + { + next = next.addDays(1); + } + return next; +} + +QDateTime nextWeeklyOccurrence(const QDateTime &now, int weekday, const QTime &time) +{ + int daysToAdd = weekday - now.date().dayOfWeek(); + QDateTime next(now.date().addDays(daysToAdd), time); + if (daysToAdd < 0 || next <= now) + { + next = next.addDays(7); + } + return next; +} + +QDateTime nextMonthlyOccurrence(const QDateTime &now, int monthDay, const QTime &time) +{ + if (monthDay < 1 || monthDay > 31) + { + return {}; + } + + QDate monthCursor(now.date().year(), now.date().month(), 1); + for (int attempt = 0; attempt < 240; ++attempt) + { + const QDate date(monthCursor.year(), monthCursor.month(), monthDay); + if (date.isValid()) + { + const QDateTime next(date, time); + if (next > now) + { + return next; + } + } + monthCursor = monthCursor.addMonths(1); + } + + return {}; +} + +bool containsUnsupportedRecurrence(const QString &text) +{ + static const QRegularExpression intervalExpression(QStringLiteral("每\\s*([0-9]+|[一二两三四五六七八九十]+)\\s*(天|周|星期|月)")); + return containsAny(text, { + QStringLiteral("每年"), + QStringLiteral("工作日"), + QStringLiteral("重复"), + QStringLiteral("每隔"), + QStringLiteral("隔天"), + QStringLiteral("每季度"), + QStringLiteral("每季"), + QStringLiteral("每小时"), + QStringLiteral("每分钟"), + QStringLiteral("农历"), + }) || intervalExpression.match(text).hasMatch(); +} } ReminderCommand ReminderParser::parse(const QString &text, const QDateTime &now) const @@ -293,18 +428,15 @@ ReminderCommand ReminderParser::parseCreateCommand(const QString &text, const QD { const QDate currentDate = now.date(); - if (containsAny(text, { - QStringLiteral("每天"), - QStringLiteral("每日"), - QStringLiteral("每周"), - QStringLiteral("每星期"), - QStringLiteral("每月"), - QStringLiteral("每年"), - QStringLiteral("工作日"), - QStringLiteral("重复"), - })) + const ReminderCommand recurringCommand = parseRecurringCreateCommand(text, now); + if (recurringCommand.type != ReminderCommandType::Invalid || !recurringCommand.errorMessage.isEmpty()) { - return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("重复提醒尚未支持,目前只能创建一次性提醒。")}; + return recurringCommand; + } + + if (containsUnsupportedRecurrence(text)) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("暂不支持该重复提醒规则,目前支持每天、每周、每月。")}; } const QRegularExpression relativeMinutesExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*分钟后")); @@ -448,9 +580,108 @@ ReminderCommand ReminderParser::parseCreateCommand(const QString &text, const QD return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到提醒时间。支持如“10分钟后提醒我喝水”“明天9点提醒我开会”。")}; } +ReminderCommand ReminderParser::parseRecurringCreateCommand(const QString &text, const QDateTime &now) const +{ + QRegularExpressionMatch match; + + if (text.contains(QStringLiteral("每月")) + && (text.contains(QStringLiteral("最后")) || text.contains(QStringLiteral("月末")))) + { + return invalidRecurringCommand(text, QStringLiteral("暂不支持每月最后一天提醒,目前支持每月具体日期。")); + } + + if (text.contains(QStringLiteral("每天")) || text.contains(QStringLiteral("每日"))) + { + const ParsedReminderTime parsedTime = parseTimeExpression(text); + if (parsedTime.hour < 0) + { + return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每天9点提醒我打卡”。")); + } + + const QTime time(parsedTime.hour, parsedTime.minute); + ReminderRecurrence recurrence; + recurrence.type = ReminderRecurrenceType::Daily; + recurrence.interval = 1; + recurrence.hour = parsedTime.hour; + recurrence.minute = parsedTime.minute; + return createReminderCommand( + extractTitle(text, parsedTime.expression), + text, + nextDailyOccurrence(now, time), + recurrence); + } + + if (text.contains(QStringLiteral("每周")) || text.contains(QStringLiteral("每星期"))) + { + const QRegularExpression weeklyExpression(QStringLiteral("(?:每周|每星期)\\s*([一二三四五六日天1-7])")); + match = weeklyExpression.match(text); + const int weekday = match.hasMatch() ? weekdayFromText(match.captured(1)) : -1; + const ParsedReminderTime parsedTime = parseTimeExpression(text); + + if (weekday < 1 || parsedTime.hour < 0) + { + return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每周一上午10点提醒我周会”。")); + } + + const QTime time(parsedTime.hour, parsedTime.minute); + ReminderRecurrence recurrence; + recurrence.type = ReminderRecurrenceType::Weekly; + recurrence.interval = 1; + recurrence.weekday = weekday; + recurrence.hour = parsedTime.hour; + recurrence.minute = parsedTime.minute; + return createReminderCommand( + extractTitle(text, parsedTime.expression), + text, + nextWeeklyOccurrence(now, weekday, time), + recurrence); + } + + if (text.contains(QStringLiteral("每月"))) + { + const QRegularExpression monthlyExpression(QStringLiteral("每月\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*(?:日|号)?")); + match = monthlyExpression.match(text); + const int monthDay = match.hasMatch() ? parseSmallInteger(match.captured(1)) : -1; + const ParsedReminderTime parsedTime = parseTimeExpression(text); + + if (monthDay < 1 || monthDay > 31 || parsedTime.hour < 0) + { + return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每月3号9点提醒我交报告”。")); + } + + const QTime time(parsedTime.hour, parsedTime.minute); + const QDateTime remindAt = nextMonthlyOccurrence(now, monthDay, time); + if (!remindAt.isValid()) + { + return invalidRecurringCommand(text, QStringLiteral("没有找到有效的每月提醒日期。")); + } + + ReminderRecurrence recurrence; + recurrence.type = ReminderRecurrenceType::Monthly; + recurrence.interval = 1; + recurrence.monthDay = monthDay; + recurrence.hour = parsedTime.hour; + recurrence.minute = parsedTime.minute; + return createReminderCommand( + extractTitle(text, parsedTime.expression), + text, + remindAt, + recurrence); + } + + return {}; +} + QString ReminderParser::extractTitle(QString text, const QString &timeExpression) const { text = removeFirst(text, timeExpression); + text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*月\\s*\\d{1,2}\\s*(?:日|号)?"))); + text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*/\\s*\\d{1,2}"))); + text.remove(QRegularExpression(QStringLiteral("下周\\s*[一二三四五六日天1-7]"))); + text.remove(QRegularExpression(QStringLiteral("每周\\s*[一二三四五六日天1-7]"))); + text.remove(QRegularExpression(QStringLiteral("每星期\\s*[一二三四五六日天1-7]"))); + text.remove(QRegularExpression(QStringLiteral("每月\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*(?:日|号)?"))); + const QStringList tokensToRemove = { QStringLiteral("提醒我"), QStringLiteral("提醒"), @@ -462,6 +693,11 @@ QString ReminderParser::extractTitle(QString text, const QString &timeExpression QStringLiteral("今天"), QStringLiteral("明天"), QStringLiteral("后天"), + QStringLiteral("每天"), + QStringLiteral("每日"), + QStringLiteral("每周"), + QStringLiteral("每星期"), + QStringLiteral("每月"), }; for (const QString &token : tokensToRemove) @@ -469,10 +705,6 @@ QString ReminderParser::extractTitle(QString text, const QString &timeExpression text.remove(token); } - text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*月\\s*\\d{1,2}\\s*(?:日|号)?"))); - text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*/\\s*\\d{1,2}"))); - text.remove(QRegularExpression(QStringLiteral("下周\\s*[一二三四五六日天1-7]"))); - text = text.trimmed(); while (text.startsWith(QChar(0x3000)) || text.startsWith(QLatin1Char(' ')) || text.startsWith(QLatin1Char(',')) || text.startsWith(QChar(0xff0c))) { diff --git a/src/reminder/ReminderParser.h b/src/reminder/ReminderParser.h index 1b586fe..a37d29a 100644 --- a/src/reminder/ReminderParser.h +++ b/src/reminder/ReminderParser.h @@ -12,5 +12,6 @@ public: private: ReminderCommand parseCreateCommand(const QString &text, const QDateTime &now) const; + ReminderCommand parseRecurringCreateCommand(const QString &text, const QDateTime &now) const; QString extractTitle(QString text, const QString &timeExpression) const; }; diff --git a/src/reminder/ReminderSoundRepository.cpp b/src/reminder/ReminderSoundRepository.cpp index 3bdbb18..b78694a 100644 --- a/src/reminder/ReminderSoundRepository.cpp +++ b/src/reminder/ReminderSoundRepository.cpp @@ -73,6 +73,22 @@ bool readChunkHeader(QDataStream &stream, QByteArray *id, quint32 *size) stream >> *size; return stream.status() == QDataStream::Ok; } + +bool isSafeSoundId(const QString &soundId) +{ + static const QRegularExpression expression(QStringLiteral("^[A-Za-z0-9][A-Za-z0-9._-]*$")); + return expression.match(soundId).hasMatch() + && !soundId.contains(QLatin1Char('/')) + && !soundId.contains(QLatin1Char('\\')); +} + +bool pathStaysInsideDirectory(const QString &filePath, const QString &directoryPath) +{ + const QString absoluteFilePath = QDir::cleanPath(QFileInfo(filePath).absoluteFilePath()); + const QString absoluteDirectoryPath = QDir::cleanPath(QDir(directoryPath).absolutePath()); + return absoluteFilePath == absoluteDirectoryPath + || absoluteFilePath.startsWith(absoluteDirectoryPath + QLatin1Char('/')); +} } QString ReminderSoundRepository::defaultSoundId() @@ -227,7 +243,26 @@ bool ReminderSoundRepository::deleteUserSound(const QString &soundId, QString *e return false; } - const QString path = QDir(userSoundsRootPath()).filePath(id + QStringLiteral(".wav")); + if (!isSafeSoundId(id)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效 id 不安全。"); + } + return false; + } + + const QString userRootPath = userSoundsRootPath(); + const QString path = QDir(userRootPath).filePath(id + QStringLiteral(".wav")); + if (!pathStaysInsideDirectory(path, userRootPath)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效路径不安全。"); + } + return false; + } + QFile file(path); if (!file.exists()) { diff --git a/src/reminder/ReminderStore.cpp b/src/reminder/ReminderStore.cpp index 1a42ee8..eea3663 100644 --- a/src/reminder/ReminderStore.cpp +++ b/src/reminder/ReminderStore.cpp @@ -10,12 +10,80 @@ #include #include #include +#include #include +#include namespace { const QString ReminderStoreFileName = QStringLiteral("reminders.json"); +QJsonObject recurrenceToObject(const ReminderRecurrence &recurrence) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), reminderRecurrenceTypeToString(recurrence.type)); + object.insert(QStringLiteral("interval"), qMax(1, recurrence.interval)); + object.insert(QStringLiteral("weekday"), recurrence.weekday); + object.insert(QStringLiteral("monthDay"), recurrence.monthDay); + object.insert(QStringLiteral("hour"), recurrence.hour); + object.insert(QStringLiteral("minute"), recurrence.minute); + if (recurrence.lastTriggeredAt.isValid()) + { + object.insert(QStringLiteral("lastTriggeredAt"), recurrence.lastTriggeredAt.toString(Qt::ISODate)); + } + return object; +} + +ReminderRecurrence recurrenceFromObject(const QJsonObject &object) +{ + ReminderRecurrence recurrence; + recurrence.type = reminderRecurrenceTypeFromString(object.value(QStringLiteral("type")).toString()); + recurrence.interval = qMax(1, object.value(QStringLiteral("interval")).toInt(1)); + recurrence.weekday = object.value(QStringLiteral("weekday")).toInt(0); + recurrence.monthDay = object.value(QStringLiteral("monthDay")).toInt(0); + recurrence.hour = object.value(QStringLiteral("hour")).toInt(-1); + recurrence.minute = object.value(QStringLiteral("minute")).toInt(-1); + recurrence.lastTriggeredAt = QDateTime::fromString( + object.value(QStringLiteral("lastTriggeredAt")).toString(), + Qt::ISODate); + + if (recurrence.type == ReminderRecurrenceType::None) + { + recurrence = {}; + } + + return recurrence; +} + +bool recurrenceIsValid(const ReminderRecurrence &recurrence) +{ + if (recurrence.type == ReminderRecurrenceType::None) + { + return true; + } + + if (recurrence.interval < 1 + || recurrence.hour < 0 + || recurrence.hour > 23 + || recurrence.minute < 0 + || recurrence.minute > 59) + { + return false; + } + + if (recurrence.type == ReminderRecurrenceType::Weekly) + { + return recurrence.weekday >= 1 && recurrence.weekday <= 7; + } + + if (recurrence.type == ReminderRecurrenceType::Monthly) + { + return recurrence.monthDay >= 1 && recurrence.monthDay <= 31; + } + + return recurrence.type == ReminderRecurrenceType::Daily; +} + QJsonObject reminderToObject(const ReminderItem &item) { QJsonObject object; @@ -25,7 +93,12 @@ QJsonObject reminderToObject(const ReminderItem &item) object.insert(QStringLiteral("remindAt"), item.remindAt.toString(Qt::ISODate)); object.insert(QStringLiteral("status"), reminderStatusToString(item.status)); object.insert(QStringLiteral("createdAt"), item.createdAt.toString(Qt::ISODate)); + if (item.finishedAt.isValid()) + { + object.insert(QStringLiteral("finishedAt"), item.finishedAt.toString(Qt::ISODate)); + } object.insert(QStringLiteral("soundId"), item.soundId); + object.insert(QStringLiteral("recurrence"), recurrenceToObject(item.recurrence)); return object; } @@ -38,7 +111,14 @@ ReminderItem reminderFromObject(const QJsonObject &object) item.remindAt = QDateTime::fromString(object.value(QStringLiteral("remindAt")).toString(), Qt::ISODate); item.status = reminderStatusFromString(object.value(QStringLiteral("status")).toString()); item.createdAt = QDateTime::fromString(object.value(QStringLiteral("createdAt")).toString(), Qt::ISODate); + item.finishedAt = QDateTime::fromString(object.value(QStringLiteral("finishedAt")).toString(), Qt::ISODate); item.soundId = object.value(QStringLiteral("soundId")).toString().trimmed(); + item.recurrence = recurrenceFromObject(object.value(QStringLiteral("recurrence")).toObject()); + if (!recurrenceIsValid(item.recurrence)) + { + Logger::warning(QStringLiteral("Invalid reminder recurrence downgraded to one-shot: id=%1").arg(item.id)); + item.recurrence = {}; + } return item; } } @@ -98,6 +178,12 @@ QVector ReminderStore::load(QString *errorMessage) const item.createdAt = item.remindAt; } + if (!item.finishedAt.isValid() + && (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled)) + { + item.finishedAt = item.remindAt; + } + items.append(item); } @@ -125,8 +211,8 @@ bool ReminderStore::save(const QVector &items, QString *errorMessa QJsonObject root; root.insert(QStringLiteral("reminders"), reminders); - QFile file(storePath()); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + QSaveFile file(storePath()); + if (!file.open(QIODevice::WriteOnly)) { if (errorMessage != nullptr) { @@ -136,7 +222,27 @@ bool ReminderStore::save(const QVector &items, QString *errorMessa } const QJsonDocument document(root); - return file.write(document.toJson(QJsonDocument::Indented)) >= 0; + const QByteArray payload = document.toJson(QJsonDocument::Indented); + if (file.write(payload) != payload.size()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("写入提醒文件不完整。"); + } + file.cancelWriting(); + return false; + } + + if (!file.commit()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提交提醒文件失败。"); + } + return false; + } + + return true; } QString ReminderStore::storePath() const diff --git a/src/reminder/ReminderTypes.cpp b/src/reminder/ReminderTypes.cpp index b3fd7f3..ac2164d 100644 --- a/src/reminder/ReminderTypes.cpp +++ b/src/reminder/ReminderTypes.cpp @@ -2,6 +2,44 @@ #include +namespace +{ +QString twoDigit(int value) +{ + return QString::number(value).rightJustified(2, QLatin1Char('0')); +} + +QString weekdayDisplayText(int weekday) +{ + switch (weekday) + { + case 1: + return QStringLiteral("一"); + case 2: + return QStringLiteral("二"); + case 3: + return QStringLiteral("三"); + case 4: + return QStringLiteral("四"); + case 5: + return QStringLiteral("五"); + case 6: + return QStringLiteral("六"); + case 7: + return QStringLiteral("日"); + default: + return QStringLiteral("?"); + } +} + +QString recurrenceTimeText(const ReminderItem &item) +{ + const int hour = item.recurrence.hour >= 0 ? item.recurrence.hour : item.remindAt.time().hour(); + const int minute = item.recurrence.minute >= 0 ? item.recurrence.minute : item.remindAt.time().minute(); + return QStringLiteral("%1:%2").arg(twoDigit(hour), twoDigit(minute)); +} +} + QString reminderStatusToString(ReminderStatus status) { switch (status) @@ -33,11 +71,76 @@ ReminderStatus reminderStatusFromString(const QString &status) return ReminderStatus::Pending; } +QString reminderRecurrenceTypeToString(ReminderRecurrenceType type) +{ + switch (type) + { + case ReminderRecurrenceType::None: + return QStringLiteral("none"); + case ReminderRecurrenceType::Daily: + return QStringLiteral("daily"); + case ReminderRecurrenceType::Weekly: + return QStringLiteral("weekly"); + case ReminderRecurrenceType::Monthly: + return QStringLiteral("monthly"); + } + + return QStringLiteral("none"); +} + +ReminderRecurrenceType reminderRecurrenceTypeFromString(const QString &type) +{ + const QString normalized = type.trimmed().toLower(); + if (normalized == QStringLiteral("daily")) + { + return ReminderRecurrenceType::Daily; + } + + if (normalized == QStringLiteral("weekly")) + { + return ReminderRecurrenceType::Weekly; + } + + if (normalized == QStringLiteral("monthly")) + { + return ReminderRecurrenceType::Monthly; + } + + return ReminderRecurrenceType::None; +} + +bool reminderIsRecurring(const ReminderItem &item) +{ + return reminderIsRecurring(item.recurrence); +} + +bool reminderIsRecurring(const ReminderRecurrence &recurrence) +{ + return recurrence.type != ReminderRecurrenceType::None; +} + QString reminderDisplayTime(const QDateTime &dateTime) { return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); } +QString reminderRecurrenceDisplayText(const ReminderItem &item) +{ + switch (item.recurrence.type) + { + case ReminderRecurrenceType::None: + return QStringLiteral("一次"); + case ReminderRecurrenceType::Daily: + return QStringLiteral("每天 %1").arg(recurrenceTimeText(item)); + case ReminderRecurrenceType::Weekly: + return QStringLiteral("每周%1 %2").arg(weekdayDisplayText(item.recurrence.weekday), recurrenceTimeText(item)); + case ReminderRecurrenceType::Monthly: + return QStringLiteral("每月%1日 %2").arg(item.recurrence.monthDay).arg(recurrenceTimeText(item)); + } + + return QStringLiteral("一次"); +} + QVector sortedReminders(QVector reminders) { std::sort(reminders.begin(), reminders.end(), [](const ReminderItem &left, const ReminderItem &right) { diff --git a/src/reminder/ReminderTypes.h b/src/reminder/ReminderTypes.h index 89df221..45e2341 100644 --- a/src/reminder/ReminderTypes.h +++ b/src/reminder/ReminderTypes.h @@ -19,6 +19,25 @@ enum class ReminderCommandType Invalid, }; +enum class ReminderRecurrenceType +{ + None, + Daily, + Weekly, + Monthly, +}; + +struct ReminderRecurrence +{ + ReminderRecurrenceType type = ReminderRecurrenceType::None; + int interval = 1; + int weekday = 0; + int monthDay = 0; + int hour = -1; + int minute = -1; + QDateTime lastTriggeredAt; +}; + struct ReminderItem { QString id; @@ -27,7 +46,9 @@ struct ReminderItem QDateTime remindAt; ReminderStatus status = ReminderStatus::Pending; QDateTime createdAt; + QDateTime finishedAt; QString soundId; + ReminderRecurrence recurrence; }; struct ReminderCommand @@ -38,9 +59,15 @@ struct ReminderCommand QDateTime remindAt; QString cancelQuery; QString errorMessage; + ReminderRecurrence recurrence; }; QString reminderStatusToString(ReminderStatus status); ReminderStatus reminderStatusFromString(const QString &status); +QString reminderRecurrenceTypeToString(ReminderRecurrenceType type); +ReminderRecurrenceType reminderRecurrenceTypeFromString(const QString &type); +bool reminderIsRecurring(const ReminderItem &item); +bool reminderIsRecurring(const ReminderRecurrence &recurrence); QString reminderDisplayTime(const QDateTime &dateTime); +QString reminderRecurrenceDisplayText(const ReminderItem &item); QVector sortedReminders(QVector reminders); diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp index 03fb3fe..6b1d1bc 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -62,14 +62,15 @@ void TrayController::show() m_trayIcon.show(); } -void TrayController::showNotification(const QString &title, const QString &message) +bool TrayController::showNotification(const QString &title, const QString &message) { - if (!isAvailable() || !m_trayIcon.isVisible()) + if (!isAvailable() || !m_trayIcon.isVisible() || !QSystemTrayIcon::supportsMessages()) { - return; + return false; } m_trayIcon.showMessage(title, message, QSystemTrayIcon::Information, 10000); + return true; } void TrayController::createMenu() diff --git a/src/tray/TrayController.h b/src/tray/TrayController.h index 0f43d44..4b404e4 100644 --- a/src/tray/TrayController.h +++ b/src/tray/TrayController.h @@ -12,7 +12,7 @@ public: bool isAvailable() const; void show(); - void showNotification(const QString &title, const QString &message); + bool showNotification(const QString &title, const QString &message); private: void createMenu(); diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index c644c46..a7d4098 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -333,6 +335,7 @@ void PetWindow::resumeAnimation() void PetWindow::showBubbleMessage(const QString &message) { + hideReminderActions(); m_chatBubble->showMessage(message, bubbleAnchorPosition()); } @@ -352,8 +355,47 @@ void PetWindow::openSettingsDialog() [this](const QString &reminderId, QString *errorMessage) { return m_reminderManager && m_reminderManager->cancelReminder(reminderId, errorMessage); }, + [this](const QString &reminderId, const QString &title, const QDateTime &remindAt, const ReminderRecurrence &recurrence, ReminderItem *updatedItem, QString *errorMessage) { + if (!m_reminderManager) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提醒功能初始化失败。"); + } + return false; + } + + if (!m_reminderManager->updateReminder(reminderId, title, remindAt, recurrence, errorMessage)) + { + return false; + } + + if (updatedItem != nullptr) + { + const QVector reminders = m_reminderManager->allReminders(); + bool found = false; + for (const ReminderItem &item : reminders) + { + if (item.id == reminderId) + { + *updatedItem = item; + found = true; + break; + } + } + if (!found) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提醒已更新,但没有找到更新后的记录。"); + } + return false; + } + } + return true; + }, [this](QString *errorMessage) { - return m_reminderManager && m_reminderManager->clearFinishedReminders(errorMessage); + return m_reminderManager && m_reminderManager->pruneFinishedReminders(20, errorMessage); }, [this](const QString &soundId, double volume) { if (m_reminderSoundPlayer) @@ -402,6 +444,10 @@ void PetWindow::activateFromExternalInstance() raise(); activateWindow(); + if (m_reminderManager) + { + m_reminderManager->checkDueRemindersNow(); + } updateBubblePosition(); } @@ -410,7 +456,7 @@ void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled) m_settingsFallbackInContextMenuEnabled = enabled; } -void PetWindow::setTrayNotificationCallback(std::function callback) +void PetWindow::setTrayNotificationCallback(std::function callback) { if (m_notificationDispatcher) { @@ -547,37 +593,240 @@ bool PetWindow::handleReminderChatMessage(const QString &message) } void PetWindow::handleTriggeredReminder(const ReminderItem &item) +{ + playReminderSound(); + + if (shouldNotifyOnlyForReminder()) + { + showReminderNotification(item); + return; + } + + enqueueVisibleTriggeredReminder(item); +} + +void PetWindow::playReminderSound() { if (m_appConfig.reminderSoundEnabled && m_reminderSoundPlayer) { m_reminderSoundPlayer->play(m_appConfig.reminderSoundId, m_appConfig.reminderSoundVolume); } +} - if (!isVisible()) +void PetWindow::showReminderNotification(const ReminderItem &item) +{ + if (m_notificationDispatcher) { - if (m_notificationDispatcher) + const bool shown = m_notificationDispatcher->showReminder(QStringLiteral("定时提醒"), QStringLiteral("到时间啦:%1").arg(item.title)); + if (!shown) { - m_notificationDispatcher->showReminder(QStringLiteral("定时提醒"), QStringLiteral("到时间啦:%1").arg(item.title)); + Logger::warning(QStringLiteral("Reminder notification backend unavailable: id=%1").arg(item.id)); } return; } - if (m_dragging) + Logger::warning(QStringLiteral("Reminder notification dispatcher is unavailable: id=%1").arg(item.id)); +} + +bool PetWindow::shouldNotifyOnlyForReminder() const +{ + return !isVisible() || hasActiveAIRequest() || m_streamingChatActive; +} + +void PetWindow::enqueueVisibleTriggeredReminder(const ReminderItem &item) +{ + m_pendingVisibleTriggeredReminders.append(item); + showNextTriggeredReminder(); +} + +void PetWindow::showNextTriggeredReminder() +{ + if (m_hasActiveTriggeredReminder || m_dragging || m_pendingVisibleTriggeredReminders.isEmpty()) { - m_deferredTriggeredReminders.append(item); return; } + const ReminderItem item = m_pendingVisibleTriggeredReminders.takeFirst(); showTriggeredReminder(item); } +void PetWindow::finishActiveTriggeredReminder(bool hideBubble) +{ + hideReminderActions(); + m_hasActiveTriggeredReminder = false; + if (hideBubble && m_chatBubble) + { + m_chatBubble->hideBubble(); + } + + showNextTriggeredReminder(); +} + void PetWindow::showTriggeredReminder(const ReminderItem &item) { const QString reminderState = m_clips.contains(QStringLiteral("happy")) ? QStringLiteral("happy") : QStringLiteral("talk"); playState(reminderState, false); - showBubbleMessage(QStringLiteral("到时间啦:%1").arg(item.title)); + hideReminderActions(); + m_chatBubble->showMessage(QStringLiteral("到时间啦:%1").arg(item.title), bubbleAnchorPosition(), 0); + showReminderActions(item); +} + +void PetWindow::ensureReminderActionPanel() +{ + if (m_reminderActionPanel) + { + return; + } + + auto *panel = new QFrame(); + panel->setObjectName(QStringLiteral("ReminderActionPanel")); + panel->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + panel->setAttribute(Qt::WA_TranslucentBackground); + panel->setAttribute(Qt::WA_ShowWithoutActivating); + panel->setStyleSheet(QStringLiteral( + "QFrame#ReminderActionPanel {" + "background: #ffffff;" + "border: 1px solid #c7cdd4;" + "border-radius: 8px;" + "}" + "QPushButton {" + "background: #f1f4f7;" + "border: 1px solid #aeb6bf;" + "border-radius: 6px;" + "padding: 7px 12px;" + "color: #202124;" + "}" + "QPushButton:hover {" + "background: #e4e9ee;" + "}" + "QPushButton:pressed {" + "background: #d5dce3;" + "}")); + + auto *layout = new QVBoxLayout(panel); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(8); + + auto *dismissButton = new QPushButton(QStringLiteral("知道了"), panel); + auto *snoozeButton = new QPushButton(QStringLiteral("5分钟后再提醒"), panel); + layout->addWidget(dismissButton); + layout->addWidget(snoozeButton); + + connect(dismissButton, &QPushButton::clicked, this, [this]() { + finishActiveTriggeredReminder(true); + }); + + connect(snoozeButton, &QPushButton::clicked, this, [this]() { + if (!m_hasActiveTriggeredReminder) + { + finishActiveTriggeredReminder(true); + return; + } + + const ReminderItem item = m_activeTriggeredReminder; + hideReminderActions(); + snoozeTriggeredReminder(item); + }); + + m_reminderActionPanel.reset(panel); +} + +void PetWindow::showReminderActions(const ReminderItem &item) +{ + m_activeTriggeredReminder = item; + m_hasActiveTriggeredReminder = true; + ensureReminderActionPanel(); + if (!m_reminderActionPanel) + { + return; + } + + m_reminderActionPanel->adjustSize(); + updateReminderActionPosition(); + m_reminderActionPanel->show(); + m_reminderActionPanel->raise(); +} + +void PetWindow::hideReminderActions() +{ + m_hasActiveTriggeredReminder = false; + if (m_reminderActionPanel) + { + m_reminderActionPanel->hide(); + } +} + +void PetWindow::updateReminderActionPosition() +{ + if (!m_reminderActionPanel) + { + return; + } + + m_reminderActionPanel->adjustSize(); + const QSize panelSize = m_reminderActionPanel->sizeHint(); + const QRect petGeometry = frameGeometry(); + constexpr int PanelSideSpacing = 8; + + QPoint position( + petGeometry.right() + PanelSideSpacing, + petGeometry.center().y() - panelSize.height() / 2); + + if (QScreen *screen = screenForPopup(this)) + { + const QRect availableGeometry = screen->availableGeometry(); + const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width()); + const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height()); + if (position.x() > maxX) + { + position.setX(petGeometry.left() - panelSize.width() - PanelSideSpacing); + } + + position.setX(qBound( + availableGeometry.left(), + position.x(), + maxX)); + position.setY(qBound( + availableGeometry.top(), + position.y(), + maxY)); + } + + m_reminderActionPanel->move(position); +} + +void PetWindow::snoozeTriggeredReminder(const ReminderItem &item) +{ + m_hasActiveTriggeredReminder = false; + if (!m_reminderManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("提醒功能初始化失败。")); + QTimer::singleShot(1200, this, [this]() { + showNextTriggeredReminder(); + }); + return; + } + + ReminderItem snoozedItem; + QString errorMessage; + if (!m_reminderManager->snoozeReminder(item, 5, &snoozedItem, &errorMessage)) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(errorMessage.isEmpty() ? QStringLiteral("创建稍后提醒失败。") : errorMessage); + QTimer::singleShot(1200, this, [this]() { + showNextTriggeredReminder(); + }); + return; + } + + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("已延后提醒:%1,时间:%2").arg(snoozedItem.title, reminderDisplayTime(snoozedItem.remindAt))); + QTimer::singleShot(1200, this, [this]() { + showNextTriggeredReminder(); + }); } bool PetWindow::submitAiChatMessage(const QString &message) @@ -620,6 +869,7 @@ bool PetWindow::submitAiChatMessage(const QString &message) stopAnimationPrewarm(); playState(QStringLiteral("think"), false); + hideReminderActions(); m_streamingAssistantText.clear(); m_streamBubbleUpdateTimer.stop(); m_streamingChatActive = true; @@ -844,6 +1094,7 @@ void PetWindow::flushStreamingBubble(bool finalUpdate) return; } + hideReminderActions(); m_chatBubble->showMessage( m_streamingAssistantText, bubbleAnchorPosition(), @@ -888,6 +1139,7 @@ void PetWindow::hideEvent(QHideEvent *event) { m_chatBubble->hideBubble(); } + hideReminderActions(); if (m_chatInputDialog) { m_chatInputDialog->hide(); @@ -911,6 +1163,7 @@ void PetWindow::showEvent(QShowEvent *event) if (m_reminderManager) { m_reminderManager->start(); + m_reminderManager->checkDueRemindersNow(); } scheduleAnimationPrewarm(); } @@ -976,12 +1229,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event) m_dragging = false; playResolvedState(m_stateMachine.endDrag(), false); scheduleAnimationPrewarm(); - const QVector deferredReminders = m_deferredTriggeredReminders; - m_deferredTriggeredReminders.clear(); - for (const ReminderItem &item : deferredReminders) - { - showTriggeredReminder(item); - } + showNextTriggeredReminder(); event->accept(); return; } @@ -1087,6 +1335,7 @@ void PetWindow::addStateTestActions(QMenu *menu) void PetWindow::updateBubblePosition() { m_chatBubble->updateAnchorPosition(bubbleAnchorPosition()); + updateReminderActionPosition(); } QPoint PetWindow::bubbleAnchorPosition() const diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index cb144fc..24186e8 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -45,7 +45,7 @@ public: void openSettingsDialog(); void activateFromExternalInstance(); void setSettingsFallbackInContextMenuEnabled(bool enabled); - void setTrayNotificationCallback(std::function callback); + void setTrayNotificationCallback(std::function callback); void pauseAnimation(); void resumeAnimation(); void showBubbleMessage(const QString &message); @@ -70,7 +70,18 @@ private: bool submitAiChatMessage(const QString &message); bool handleReminderChatMessage(const QString &message); void handleTriggeredReminder(const ReminderItem &item); + void playReminderSound(); + void showReminderNotification(const ReminderItem &item); + bool shouldNotifyOnlyForReminder() const; + void enqueueVisibleTriggeredReminder(const ReminderItem &item); + void showNextTriggeredReminder(); + void finishActiveTriggeredReminder(bool hideBubble); void showTriggeredReminder(const ReminderItem &item); + void ensureReminderActionPanel(); + void showReminderActions(const ReminderItem &item); + void hideReminderActions(); + void updateReminderActionPosition(); + void snoozeTriggeredReminder(const ReminderItem &item); void clearConversation(); void cancelActiveAIRequest(); void showConversationHistory(); @@ -121,6 +132,7 @@ private: std::unique_ptr m_notificationDispatcher; std::unique_ptr m_reminderManager; std::unique_ptr m_reminderSoundPlayer; + std::unique_ptr m_reminderActionPanel; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; @@ -136,7 +148,8 @@ private: QPoint m_dragOffset; QString m_streamingAssistantText; QStringList m_animationPrewarmQueue; - QVector m_deferredTriggeredReminders; + QVector m_pendingVisibleTriggeredReminders; + ReminderItem m_activeTriggeredReminder; qint64 m_clipAccessSerial = 0; bool m_dragging; bool m_alwaysOnTop; @@ -145,4 +158,5 @@ private: bool m_streamingChatActive = false; bool m_streamingTalkStarted = false; bool m_settingsFallbackInContextMenuEnabled = true; + bool m_hasActiveTriggeredReminder = false; }; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index ddd0d2e..f2c127c 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +40,8 @@ namespace { +constexpr int ReminderHistoryRetentionDays = 20; + QString normalizedProviderName(const QString &provider) { const QString normalized = provider.trimmed().toLower(); @@ -105,11 +109,22 @@ bool reminderMatchesStatusFilter(const ReminderItem &item, const QString &filter return item.status == ReminderStatus::Pending; } -bool hasFinishedReminders(const QVector &reminders) +bool isFinishedReminder(const ReminderItem &item) { + return item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled; +} + +QDateTime reminderFinishedReferenceTime(const ReminderItem &item) +{ + return item.finishedAt.isValid() ? item.finishedAt : item.remindAt; +} + +bool hasPrunableFinishedReminders(const QVector &reminders, int retentionDays) +{ + const QDateTime cutoff = QDateTime::currentDateTime().addDays(-qMax(0, retentionDays)); for (const ReminderItem &item : reminders) { - if (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled) + if (isFinishedReminder(item) && reminderFinishedReferenceTime(item) < cutoff) { return true; } @@ -117,6 +132,158 @@ bool hasFinishedReminders(const QVector &reminders) return false; } + +const ReminderItem *findReminderById(const QVector &reminders, const QString &id) +{ + for (const ReminderItem &item : reminders) + { + if (item.id == id) + { + return &item; + } + } + + return nullptr; +} + +class ReminderEditDialog : public QDialog +{ +public: + explicit ReminderEditDialog(const ReminderItem &item, QWidget *parent = nullptr) + : QDialog(parent) + , m_titleEdit(new QLineEdit(this)) + , m_dateTimeEdit(new QDateTimeEdit(this)) + , m_recurrenceComboBox(new QComboBox(this)) + , m_weekdayComboBox(new QComboBox(this)) + , m_monthDaySpinBox(new QSpinBox(this)) + { + setWindowTitle(QStringLiteral("编辑提醒")); + setModal(true); + resize(420, 240); + + m_titleEdit->setText(item.title); + + const QDateTime minimumDateTime = QDateTime::currentDateTime().addSecs(1); + const QDateTime selectedDateTime = item.remindAt.isValid() && item.remindAt > minimumDateTime + ? item.remindAt + : minimumDateTime; + m_dateTimeEdit->setCalendarPopup(true); + m_dateTimeEdit->setDisplayFormat(QStringLiteral("yyyy-MM-dd HH:mm")); + m_dateTimeEdit->setMinimumDateTime(minimumDateTime); + m_dateTimeEdit->setDateTime(selectedDateTime); + + m_recurrenceComboBox->addItem(QStringLiteral("一次"), reminderRecurrenceTypeToString(ReminderRecurrenceType::None)); + m_recurrenceComboBox->addItem(QStringLiteral("每天"), reminderRecurrenceTypeToString(ReminderRecurrenceType::Daily)); + m_recurrenceComboBox->addItem(QStringLiteral("每周"), reminderRecurrenceTypeToString(ReminderRecurrenceType::Weekly)); + m_recurrenceComboBox->addItem(QStringLiteral("每月"), reminderRecurrenceTypeToString(ReminderRecurrenceType::Monthly)); + const int recurrenceIndex = m_recurrenceComboBox->findData(reminderRecurrenceTypeToString(item.recurrence.type)); + m_recurrenceComboBox->setCurrentIndex(recurrenceIndex >= 0 ? recurrenceIndex : 0); + + m_weekdayComboBox->addItem(QStringLiteral("周一"), 1); + m_weekdayComboBox->addItem(QStringLiteral("周二"), 2); + m_weekdayComboBox->addItem(QStringLiteral("周三"), 3); + m_weekdayComboBox->addItem(QStringLiteral("周四"), 4); + m_weekdayComboBox->addItem(QStringLiteral("周五"), 5); + m_weekdayComboBox->addItem(QStringLiteral("周六"), 6); + m_weekdayComboBox->addItem(QStringLiteral("周日"), 7); + const int weekday = item.recurrence.weekday >= 1 && item.recurrence.weekday <= 7 + ? item.recurrence.weekday + : selectedDateTime.date().dayOfWeek(); + const int weekdayIndex = m_weekdayComboBox->findData(weekday); + m_weekdayComboBox->setCurrentIndex(weekdayIndex >= 0 ? weekdayIndex : 0); + + m_monthDaySpinBox->setRange(1, 31); + m_monthDaySpinBox->setValue(item.recurrence.monthDay >= 1 && item.recurrence.monthDay <= 31 + ? item.recurrence.monthDay + : selectedDateTime.date().day()); + + auto *formLayout = new QFormLayout(); + formLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + formLayout->addRow(QStringLiteral("标题"), m_titleEdit); + formLayout->addRow(QStringLiteral("下一次时间"), m_dateTimeEdit); + formLayout->addRow(QStringLiteral("重复"), m_recurrenceComboBox); + formLayout->addRow(QStringLiteral("每周"), m_weekdayComboBox); + formLayout->addRow(QStringLiteral("每月日期"), m_monthDaySpinBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, [this]() { + accept(); + }); + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + reject(); + }); + connect(m_recurrenceComboBox, &QComboBox::currentIndexChanged, this, [this]() { + updateRecurrenceControls(); + }); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(formLayout); + layout->addWidget(buttonBox); + updateRecurrenceControls(); + } + + QString title() const + { + return m_titleEdit->text().trimmed(); + } + + QDateTime remindAt() const + { + return m_dateTimeEdit->dateTime(); + } + + ReminderRecurrence recurrence() const + { + ReminderRecurrence recurrence; + recurrence.type = reminderRecurrenceTypeFromString(m_recurrenceComboBox->currentData().toString()); + if (recurrence.type == ReminderRecurrenceType::None) + { + return {}; + } + + const QTime time = m_dateTimeEdit->dateTime().time(); + recurrence.interval = 1; + recurrence.hour = time.hour(); + recurrence.minute = time.minute(); + if (recurrence.type == ReminderRecurrenceType::Weekly) + { + recurrence.weekday = m_weekdayComboBox->currentData().toInt(); + } + else if (recurrence.type == ReminderRecurrenceType::Monthly) + { + recurrence.monthDay = m_monthDaySpinBox->value(); + } + + return recurrence; + } + +protected: + void accept() override + { + const ReminderRecurrenceType type = reminderRecurrenceTypeFromString(m_recurrenceComboBox->currentData().toString()); + if (type == ReminderRecurrenceType::None && m_dateTimeEdit->dateTime() <= QDateTime::currentDateTime()) + { + QMessageBox::warning(this, QStringLiteral("编辑提醒"), QStringLiteral("提醒时间必须晚于当前时间。")); + return; + } + + QDialog::accept(); + } + +private: + void updateRecurrenceControls() + { + const ReminderRecurrenceType type = reminderRecurrenceTypeFromString(m_recurrenceComboBox->currentData().toString()); + m_weekdayComboBox->setEnabled(type == ReminderRecurrenceType::Weekly); + m_monthDaySpinBox->setEnabled(type == ReminderRecurrenceType::Monthly); + } + + QLineEdit *m_titleEdit = nullptr; + QDateTimeEdit *m_dateTimeEdit = nullptr; + QComboBox *m_recurrenceComboBox = nullptr; + QComboBox *m_weekdayComboBox = nullptr; + QSpinBox *m_monthDaySpinBox = nullptr; +}; } SettingsDialog::SettingsDialog( @@ -126,6 +293,7 @@ SettingsDialog::SettingsDialog( std::function aiTestBlocked, std::function clearConversationHistoryCallback, std::function cancelReminderCallback, + std::function updateReminderCallback, std::function clearFinishedRemindersCallback, std::function playReminderSoundCallback, QWidget *parent) @@ -159,7 +327,8 @@ SettingsDialog::SettingsDialog( , m_reminderStatusFilterComboBox(new QComboBox(this)) , m_reminderListWidget(new QListWidget(this)) , m_cancelReminderButton(new QPushButton(QStringLiteral("取消选中提醒"), this)) - , m_clearFinishedRemindersButton(new QPushButton(QStringLiteral("清理历史"), this)) + , m_editReminderButton(new QPushButton(QStringLiteral("编辑选中提醒"), this)) + , m_clearFinishedRemindersButton(new QPushButton(QStringLiteral("清理20天前历史"), this)) , m_reminderStatusLabel(new QLabel(this)) , m_reminderSoundEnabledCheckBox(new QCheckBox(QStringLiteral("启用提醒音效"), this)) , m_reminderSoundVolumeSpinBox(new QSpinBox(this)) @@ -174,6 +343,7 @@ SettingsDialog::SettingsDialog( , m_aiTestBlocked(std::move(aiTestBlocked)) , m_clearConversationHistory(std::move(clearConversationHistoryCallback)) , m_cancelReminder(std::move(cancelReminderCallback)) + , m_updateReminder(std::move(updateReminderCallback)) , m_clearFinishedReminders(std::move(clearFinishedRemindersCallback)) , m_playReminderSound(std::move(playReminderSoundCallback)) { @@ -378,7 +548,7 @@ SettingsDialog::SettingsDialog( auto *reminderTitleLabel = new QLabel(QStringLiteral("提醒"), this); reminderTitleLabel->setObjectName(QStringLiteral("PageTitle")); - auto *reminderHintLabel = new QLabel(QStringLiteral("这里显示通过聊天创建的待触发提醒。内置提醒音效不可删除,导入音效仅支持 PCM wav。"), this); + auto *reminderHintLabel = new QLabel(QStringLiteral("这里显示通过聊天创建的提醒,重复提醒会展示下一次触发时间。内置提醒音效不可删除,导入音效仅支持 PCM wav。"), this); reminderHintLabel->setObjectName(QStringLiteral("HintLabel")); reminderHintLabel->setWordWrap(true); @@ -392,6 +562,7 @@ SettingsDialog::SettingsDialog( reminderListLayout->addWidget(m_reminderListWidget); auto *reminderActionLayout = new QHBoxLayout(); reminderActionLayout->addWidget(m_cancelReminderButton); + reminderActionLayout->addWidget(m_editReminderButton); reminderActionLayout->addWidget(m_clearFinishedRemindersButton); reminderActionLayout->addWidget(m_reminderStatusLabel, 1); reminderListLayout->addLayout(reminderActionLayout); @@ -572,6 +743,9 @@ SettingsDialog::SettingsDialog( connect(m_cancelReminderButton, &QPushButton::clicked, this, [this]() { cancelSelectedReminder(); }); + connect(m_editReminderButton, &QPushButton::clicked, this, [this]() { + editSelectedReminder(); + }); connect(m_clearFinishedRemindersButton, &QPushButton::clicked, this, [this]() { clearFinishedReminders(); }); @@ -967,8 +1141,8 @@ void SettingsDialog::reloadReminderList() } auto *listItem = new QListWidgetItem( - QStringLiteral("%1 %2 %3 %4") - .arg(reminderDisplayTime(item.remindAt), reminderStatusDisplayText(item.status), item.title, item.originalText), + QStringLiteral("%1 %2 %3 %4 %5") + .arg(reminderDisplayTime(item.remindAt), reminderStatusDisplayText(item.status), reminderRecurrenceDisplayText(item), item.title, item.originalText), m_reminderListWidget); listItem->setData(Qt::UserRole, item.id); listItem->setData(Qt::UserRole + 1, reminderStatusToString(item.status)); @@ -1042,7 +1216,8 @@ void SettingsDialog::updateReminderActionButtons() const bool selectedPending = item != nullptr && item->data(Qt::UserRole + 1).toString() == QStringLiteral("pending"); m_cancelReminderButton->setEnabled(selectedPending); - m_clearFinishedRemindersButton->setEnabled(hasFinishedReminders(m_reminders)); + m_editReminderButton->setEnabled(selectedPending); + m_clearFinishedRemindersButton->setEnabled(hasPrunableFinishedReminders(m_reminders, ReminderHistoryRetentionDays)); } void SettingsDialog::cancelSelectedReminder() @@ -1078,6 +1253,7 @@ void SettingsDialog::cancelSelectedReminder() if (reminder.id == reminderId) { reminder.status = ReminderStatus::Canceled; + reminder.finishedAt = QDateTime::currentDateTime(); break; } } @@ -1085,18 +1261,68 @@ void SettingsDialog::cancelSelectedReminder() m_reminderStatusLabel->setText(QStringLiteral("已取消提醒:%1").arg(reminderId)); } +void SettingsDialog::editSelectedReminder() +{ + QListWidgetItem *item = m_reminderListWidget->currentItem(); + if (item == nullptr) + { + return; + } + + const QString reminderId = item->data(Qt::UserRole).toString(); + const ReminderItem *reminder = findReminderById(m_reminders, reminderId); + if (reminder == nullptr || reminder->status != ReminderStatus::Pending) + { + QMessageBox::information( + this, + QStringLiteral("编辑提醒"), + QStringLiteral("只能编辑待提醒事项。")); + return; + } + + ReminderEditDialog dialog(*reminder, this); + if (dialog.exec() != QDialog::Accepted) + { + return; + } + + ReminderItem updatedReminder; + QString errorMessage; + if (!m_updateReminder + || !m_updateReminder(reminderId, dialog.title(), dialog.remindAt(), dialog.recurrence(), &updatedReminder, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("编辑提醒失败"), + errorMessage.isEmpty() ? QStringLiteral("编辑提醒失败。") : errorMessage); + return; + } + + for (ReminderItem &existingReminder : m_reminders) + { + if (existingReminder.id == reminderId) + { + existingReminder = updatedReminder; + break; + } + } + + reloadReminderList(); + m_reminderStatusLabel->setText(QStringLiteral("已更新提醒:%1").arg(updatedReminder.id)); +} + void SettingsDialog::clearFinishedReminders() { - if (!hasFinishedReminders(m_reminders)) + if (!hasPrunableFinishedReminders(m_reminders, ReminderHistoryRetentionDays)) { - m_reminderStatusLabel->setText(QStringLiteral("没有可清理的历史提醒。")); + m_reminderStatusLabel->setText(QStringLiteral("没有可清理的20天前历史提醒。")); return; } const QMessageBox::StandardButton result = QMessageBox::warning( this, QStringLiteral("清理提醒历史"), - QStringLiteral("确定要清理所有已触发和已取消的提醒记录吗?\n\n待提醒事项不会被删除。"), + QStringLiteral("确定要清理20天前的已触发和已取消提醒记录吗?\n\n最近20天历史会保留,待提醒事项不会被删除。"), QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel); if (result != QMessageBox::Yes) @@ -1116,15 +1342,15 @@ void SettingsDialog::clearFinishedReminders() for (int index = m_reminders.size() - 1; index >= 0; --index) { - const ReminderStatus status = m_reminders.at(index).status; - if (status == ReminderStatus::Triggered || status == ReminderStatus::Canceled) + if (isFinishedReminder(m_reminders.at(index)) + && reminderFinishedReferenceTime(m_reminders.at(index)) < QDateTime::currentDateTime().addDays(-ReminderHistoryRetentionDays)) { m_reminders.removeAt(index); } } reloadReminderList(); - m_reminderStatusLabel->setText(QStringLiteral("已清理提醒历史。")); + m_reminderStatusLabel->setText(QStringLiteral("已清理20天前提醒历史。")); } void SettingsDialog::importReminderSound() diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 5b4b1f0..3ca0ecc 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -30,6 +30,7 @@ public: std::function aiTestBlocked, std::function clearConversationHistoryCallback, std::function cancelReminderCallback, + std::function updateReminderCallback, std::function clearFinishedRemindersCallback, std::function playReminderSoundCallback, QWidget *parent = nullptr); @@ -61,6 +62,7 @@ private: void updateReminderSoundButtons(); void updateReminderActionButtons(); void cancelSelectedReminder(); + void editSelectedReminder(); void clearFinishedReminders(); void importReminderSound(); void deleteSelectedReminderSound(); @@ -97,6 +99,7 @@ private: QComboBox *m_reminderStatusFilterComboBox = nullptr; QListWidget *m_reminderListWidget = nullptr; QPushButton *m_cancelReminderButton = nullptr; + QPushButton *m_editReminderButton = nullptr; QPushButton *m_clearFinishedRemindersButton = nullptr; QLabel *m_reminderStatusLabel = nullptr; QCheckBox *m_reminderSoundEnabledCheckBox = nullptr; @@ -114,6 +117,7 @@ private: std::function m_aiTestBlocked; std::function m_clearConversationHistory; std::function m_cancelReminder; + std::function m_updateReminder; std::function m_clearFinishedReminders; std::function m_playReminderSound; std::unique_ptr m_testProvider;