feat: add reminder scheduling and sound controls
This commit is contained in:
+18
-1
@@ -10,7 +10,7 @@ set(CMAKE_AUTOMOC OFF)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
|
||||
|
||||
qt_add_executable(QtDesktopPet
|
||||
main.cpp
|
||||
@@ -50,6 +50,22 @@ qt_add_executable(QtDesktopPet
|
||||
src/config/ConfigManager.cpp
|
||||
src/config/SecretStore.h
|
||||
src/config/SecretStore.cpp
|
||||
src/notification/NotificationDispatcher.h
|
||||
src/notification/NotificationDispatcher.cpp
|
||||
src/reminder/ReminderCommandHandler.h
|
||||
src/reminder/ReminderCommandHandler.cpp
|
||||
src/reminder/ReminderManager.h
|
||||
src/reminder/ReminderManager.cpp
|
||||
src/reminder/ReminderParser.h
|
||||
src/reminder/ReminderParser.cpp
|
||||
src/reminder/ReminderSoundPlayer.h
|
||||
src/reminder/ReminderSoundPlayer.cpp
|
||||
src/reminder/ReminderSoundRepository.h
|
||||
src/reminder/ReminderSoundRepository.cpp
|
||||
src/reminder/ReminderStore.h
|
||||
src/reminder/ReminderStore.cpp
|
||||
src/reminder/ReminderTypes.h
|
||||
src/reminder/ReminderTypes.cpp
|
||||
src/state/PetStateMachine.h
|
||||
src/state/PetStateMachine.cpp
|
||||
src/tray/TrayController.h
|
||||
@@ -79,6 +95,7 @@ target_compile_definitions(QtDesktopPet
|
||||
|
||||
target_link_libraries(QtDesktopPet
|
||||
PRIVATE
|
||||
Qt6::Multimedia
|
||||
Qt6::Network
|
||||
Qt6::Widgets
|
||||
)
|
||||
|
||||
@@ -35,6 +35,9 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
||||
- Google Gemini 原生聊天请求
|
||||
- 角色文件夹导入和角色切换
|
||||
- 删除用户导入角色
|
||||
- 本地一次性提醒:聊天创建、查询、取消,重启后 pending 提醒不丢
|
||||
- 提醒到点气泡提示、拖动后延迟提示和隐藏时托盘通知
|
||||
- 提醒音效切换、试听、用户 wav 导入和删除
|
||||
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
||||
|
||||
@@ -50,6 +53,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
||||
- C++17
|
||||
- Qt 6 Widgets
|
||||
- Qt 6 Network
|
||||
- Qt 6 Multimedia
|
||||
- CMake
|
||||
- PNG 图片序列帧
|
||||
- JSON 配置文件
|
||||
@@ -145,6 +149,62 @@ resources/characters/shiroko/
|
||||
- 隐藏到托盘时可释放非保护动画缓存
|
||||
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为
|
||||
|
||||
## 定时提醒和音效
|
||||
|
||||
当前支持通过聊天输入创建一次性本地提醒,例如:
|
||||
|
||||
```text
|
||||
10分钟后提醒我喝水
|
||||
半小时后提醒我休息
|
||||
一个半小时后提醒我喝水
|
||||
明天9点提醒我开会
|
||||
后天9点提醒我开会
|
||||
6月3日9点提醒我提交
|
||||
下周一上午10点提醒我周会
|
||||
提醒列表
|
||||
取消喝水提醒
|
||||
```
|
||||
|
||||
提醒数据保存到:
|
||||
|
||||
```text
|
||||
QStandardPaths::AppConfigLocation/reminders.json
|
||||
```
|
||||
|
||||
提醒文件损坏时会备份为:
|
||||
|
||||
```text
|
||||
reminders.broken.yyyyMMdd-HHmmss.json
|
||||
```
|
||||
|
||||
内置提醒音效位于:
|
||||
|
||||
```text
|
||||
resources/sounds/reminders/
|
||||
```
|
||||
|
||||
用户导入的提醒音效保存到:
|
||||
|
||||
```text
|
||||
QStandardPaths::AppDataLocation/sounds/reminders/
|
||||
```
|
||||
|
||||
音效规则:
|
||||
|
||||
- 默认音效为 `reminder_default`
|
||||
- 提醒触发时使用当前设置页选择的全局音效;修改音效后对所有未触发提醒立即生效
|
||||
- 内置音效可切换、可试听,但不能在设置页删除
|
||||
- 用户音效只支持导入 PCM wav
|
||||
- 用户导入音效可切换、可试听、可删除
|
||||
- 删除当前用户音效后会回退到 `reminder_default`
|
||||
|
||||
触发规则:
|
||||
|
||||
- 桌宠可见时显示气泡,不发系统通知
|
||||
- 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡
|
||||
- 用户拖动中不打断 `drag`,拖动结束后显示气泡
|
||||
- 重复提醒尚未支持,包含“每天 / 每周 / 每月”等语义时会提示暂不支持
|
||||
|
||||
## 配置和日志
|
||||
|
||||
应用配置保存到 Qt 标准配置目录:
|
||||
@@ -197,6 +257,7 @@ QtDesktopPet.exe
|
||||
Qt runtime
|
||||
resources/characters/
|
||||
resources/icons/
|
||||
resources/sounds/
|
||||
LICENSE
|
||||
README.md
|
||||
```
|
||||
@@ -278,7 +339,7 @@ reports/perf/
|
||||
docs/performance_stability_check.md
|
||||
```
|
||||
|
||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
|
||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。
|
||||
|
||||
## AI 配置和聊天
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
- AI 请求取消和对话清空
|
||||
- 角色文件夹导入和角色切换
|
||||
- 删除用户导入角色
|
||||
- 本地一次性提醒、提醒列表、取消提醒和到点通知
|
||||
- 内置/用户提醒音效切换、导入、删除和试听
|
||||
- Windows 打包脚本和 Inno Setup 安装器脚本
|
||||
- Release exe 双击不弹控制台窗口
|
||||
|
||||
@@ -294,12 +296,12 @@ IP 定位隐私说明
|
||||
阶段 5:语音对话 / 更复杂 Agent 能力
|
||||
```
|
||||
|
||||
当前最推荐先做:
|
||||
当前结构收口和定时提醒已经进入实现阶段。下一步最推荐继续做:
|
||||
|
||||
```text
|
||||
1. IntentRouter / CommandDispatcher
|
||||
2. 定时提醒
|
||||
3. 天气查询
|
||||
1. 天气查询
|
||||
2. 本地文件操作安全边界
|
||||
3. 联网搜索
|
||||
```
|
||||
|
||||
---
|
||||
@@ -378,6 +380,20 @@ CommandDispatcher::dispatch(userText)
|
||||
|
||||
桌宠创建本地提醒,到点后气泡提示或托盘提示。
|
||||
|
||||
当前实现状态:
|
||||
|
||||
```text
|
||||
已新增 src/reminder/ 模块
|
||||
已支持一次性提醒解析、JSON 持久化、启动后加载、到点触发和状态标记
|
||||
已支持聊天创建 / 查询 / 取消提醒
|
||||
已支持设置页按状态查看提醒、取消 pending 提醒、清理已触发/已取消历史
|
||||
已支持 reminder_default / reminder_soft 内置音效
|
||||
已支持用户 wav 音效导入、删除、切换和试听
|
||||
提醒触发时使用当前设置页选择的全局音效,ReminderItem.soundId 仅保留为历史兼容字段
|
||||
已接入 Qt Multimedia / QSoundEffect 播放提醒音效
|
||||
已预留 NotificationDispatcher,当前 Windows 仍由托盘通知承接
|
||||
```
|
||||
|
||||
## 5.2 第一版范围
|
||||
|
||||
要做:
|
||||
@@ -408,12 +424,18 @@ CommandDispatcher::dispatch(userText)
|
||||
```text
|
||||
src/reminder/
|
||||
├── ReminderTypes.h
|
||||
├── ReminderCommandHandler.h
|
||||
├── ReminderCommandHandler.cpp
|
||||
├── ReminderParser.h
|
||||
├── ReminderParser.cpp
|
||||
├── ReminderManager.h
|
||||
├── ReminderManager.cpp
|
||||
├── ReminderStore.h
|
||||
└── ReminderStore.cpp
|
||||
├── ReminderStore.cpp
|
||||
├── ReminderSoundRepository.h
|
||||
├── ReminderSoundRepository.cpp
|
||||
├── ReminderSoundPlayer.h
|
||||
└── ReminderSoundPlayer.cpp
|
||||
```
|
||||
|
||||
## 5.4 数据结构建议
|
||||
@@ -425,8 +447,9 @@ struct ReminderItem
|
||||
QString title;
|
||||
QString originalText;
|
||||
QDateTime remindAt;
|
||||
bool triggered = false;
|
||||
ReminderStatus status = ReminderStatus::Pending;
|
||||
QDateTime createdAt;
|
||||
QString soundId; // 历史兼容字段,触发时不再读取
|
||||
};
|
||||
```
|
||||
|
||||
@@ -448,8 +471,9 @@ QStandardPaths::AppConfigLocation/reminders.json
|
||||
"title": "提交作业",
|
||||
"originalText": "晚上8点提醒我提交作业",
|
||||
"remindAt": "2026-06-01T20:00:00",
|
||||
"triggered": false,
|
||||
"createdAt": "2026-06-01T15:20:00"
|
||||
"status": "pending",
|
||||
"createdAt": "2026-06-01T15:20:00",
|
||||
"soundId": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -475,13 +499,21 @@ reminders.broken.yyyyMMdd-HHmmss.json
|
||||
下午3点
|
||||
明天9点
|
||||
明天上午10点
|
||||
后天9点
|
||||
今天下午3点
|
||||
6月3日9点
|
||||
6/3 09:00
|
||||
下周一上午10点
|
||||
10分钟后
|
||||
半小时后
|
||||
一个半小时后
|
||||
一小时后
|
||||
1小时后
|
||||
两小时后
|
||||
2小时后
|
||||
```
|
||||
|
||||
如果规则解析失败,后续可以再接 AI 解析。
|
||||
如果规则解析失败,后续可以再接 AI 解析。包含“每天 / 每周 / 每月 / 工作日 / 重复”等语义时,当前只返回“重复提醒尚未支持”,不创建一次性提醒。
|
||||
|
||||
## 5.7 AI 辅助解析的设计边界
|
||||
|
||||
@@ -524,9 +556,9 @@ userText
|
||||
建议行为:
|
||||
|
||||
```text
|
||||
桌宠可见:显示 ChatBubble + 切 talk 或 happy
|
||||
桌宠隐藏:系统托盘通知
|
||||
用户拖动中:不打断 drag,拖动结束后显示
|
||||
桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知
|
||||
桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡
|
||||
用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知
|
||||
```
|
||||
|
||||
提醒文案:
|
||||
@@ -988,12 +1020,8 @@ CustomSearchProvider
|
||||
## 9.2 第二步:定时提醒
|
||||
|
||||
```text
|
||||
1. ReminderTypes
|
||||
2. ReminderStore
|
||||
3. ReminderParser
|
||||
4. ReminderManager
|
||||
5. 到点气泡和托盘通知
|
||||
6. 设置页增加提醒列表,可后置
|
||||
当前已落地一次性提醒、提醒列表、取消提醒、到点气泡/托盘通知和提醒音效管理。
|
||||
后续可继续补确认/稍后提醒、重复提醒和跨平台通知实现。
|
||||
```
|
||||
|
||||
## 9.3 第三步:天气查询
|
||||
@@ -1066,8 +1094,8 @@ CustomSearchProvider
|
||||
其中:
|
||||
|
||||
```text
|
||||
定时提醒:最适合第一个落地
|
||||
天气:第二个落地
|
||||
定时提醒:已作为第一个工具能力落地
|
||||
天气:建议第二个落地
|
||||
本地文件操作:风险较高,第三个落地
|
||||
联网搜索:通用能力,最后落地
|
||||
```
|
||||
|
||||
@@ -1408,7 +1408,7 @@ Windows 下不能只拷贝 exe。
|
||||
1. 用户手动完成 Release 构建
|
||||
2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径
|
||||
3. 脚本调用 windeployqt 收集 Qt 运行库
|
||||
4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md
|
||||
4. 脚本复制 resources/characters、resources/icons、resources/sounds、LICENSE、README.md
|
||||
5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip
|
||||
6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss
|
||||
7. 安装器默认最终输出到项目根目录
|
||||
|
||||
@@ -76,6 +76,9 @@ 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);
|
||||
});
|
||||
trayController.show();
|
||||
|
||||
QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,11 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
|
||||
return {CommandDispatchAction::Chat, intent, intent.text};
|
||||
}
|
||||
|
||||
if (intent.type == UserIntentType::Reminder)
|
||||
{
|
||||
return {CommandDispatchAction::Reminder, intent, intent.text};
|
||||
}
|
||||
|
||||
return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
enum class CommandDispatchAction
|
||||
{
|
||||
Chat,
|
||||
Reminder,
|
||||
UnsupportedTool,
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ struct AppConfig
|
||||
int animationCacheLimitMb = 180;
|
||||
bool unloadAnimationsWhenHidden = true;
|
||||
QString characterId = QStringLiteral("shiroko");
|
||||
QString reminderSoundId = QStringLiteral("reminder_default");
|
||||
bool reminderSoundEnabled = true;
|
||||
double reminderSoundVolume = 0.8;
|
||||
int requestContextMessageLimit = 12;
|
||||
int memoryHistoryMessageLimit = 200;
|
||||
bool saveConversationHistory = false;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QStandardPaths>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -57,6 +58,15 @@ QJsonObject characterObjectFromConfig(const AppConfig &config)
|
||||
return character;
|
||||
}
|
||||
|
||||
QJsonObject reminderObjectFromConfig(const AppConfig &config)
|
||||
{
|
||||
QJsonObject reminder;
|
||||
reminder.insert(QStringLiteral("soundId"), config.reminderSoundId);
|
||||
reminder.insert(QStringLiteral("soundEnabled"), config.reminderSoundEnabled);
|
||||
reminder.insert(QStringLiteral("soundVolume"), qBound(0.0, config.reminderSoundVolume, 1.0));
|
||||
return reminder;
|
||||
}
|
||||
|
||||
QString normalizedProviderName(const QString &provider)
|
||||
{
|
||||
const QString normalized = provider.trimmed().toLower();
|
||||
@@ -258,6 +268,26 @@ AppConfig ConfigManager::loadAppConfig() const
|
||||
config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed();
|
||||
}
|
||||
|
||||
const QJsonObject reminder = root.value(QStringLiteral("reminder")).toObject();
|
||||
if (reminder.contains(QStringLiteral("soundId")))
|
||||
{
|
||||
config.reminderSoundId = reminder.value(QStringLiteral("soundId")).toString(config.reminderSoundId).trimmed();
|
||||
if (config.reminderSoundId.isEmpty())
|
||||
{
|
||||
config.reminderSoundId = QStringLiteral("reminder_default");
|
||||
}
|
||||
}
|
||||
|
||||
if (reminder.contains(QStringLiteral("soundEnabled")))
|
||||
{
|
||||
config.reminderSoundEnabled = reminder.value(QStringLiteral("soundEnabled")).toBool(config.reminderSoundEnabled);
|
||||
}
|
||||
|
||||
if (reminder.contains(QStringLiteral("soundVolume")))
|
||||
{
|
||||
config.reminderSoundVolume = qBound(0.0, reminder.value(QStringLiteral("soundVolume")).toDouble(config.reminderSoundVolume), 1.0);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -346,6 +376,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
||||
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
|
||||
|
||||
QFile file(appConfigPath());
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "NotificationDispatcher.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
void NotificationDispatcher::setShowCallback(ShowCallback callback)
|
||||
{
|
||||
m_showCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void NotificationDispatcher::showReminder(const QString &title, const QString &message) const
|
||||
{
|
||||
if (m_showCallback)
|
||||
{
|
||||
m_showCallback(title, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class NotificationDispatcher
|
||||
{
|
||||
public:
|
||||
using ShowCallback = std::function<void(const QString &, const QString &)>;
|
||||
|
||||
void setShowCallback(ShowCallback callback);
|
||||
void showReminder(const QString &title, const QString &message) const;
|
||||
|
||||
private:
|
||||
ShowCallback m_showCallback;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "ReminderCommandHandler.h"
|
||||
|
||||
#include "ReminderManager.h"
|
||||
|
||||
ReminderCommandResult ReminderCommandHandler::handle(const QString &text, ReminderManager &manager)
|
||||
{
|
||||
const ReminderCommand command = manager.parseCommand(text);
|
||||
switch (command.type)
|
||||
{
|
||||
case ReminderCommandType::Create:
|
||||
{
|
||||
ReminderItem item;
|
||||
QString errorMessage;
|
||||
if (!manager.createReminder(command.title, command.originalText, command.remindAt, &item, &errorMessage))
|
||||
{
|
||||
return {false, errorMessage.isEmpty() ? QStringLiteral("创建提醒失败。") : errorMessage, {}};
|
||||
}
|
||||
|
||||
return {
|
||||
true,
|
||||
QStringLiteral("已设置提醒:%1,时间:%2").arg(item.title, reminderDisplayTime(item.remindAt)),
|
||||
item,
|
||||
};
|
||||
}
|
||||
case ReminderCommandType::List:
|
||||
return {true, manager.pendingReminderSummary(), {}};
|
||||
case ReminderCommandType::Cancel:
|
||||
{
|
||||
ReminderItem item;
|
||||
QString errorMessage;
|
||||
if (!manager.cancelReminderByQuery(command.cancelQuery, &item, &errorMessage))
|
||||
{
|
||||
return {false, errorMessage.isEmpty() ? QStringLiteral("取消提醒失败。") : errorMessage, {}};
|
||||
}
|
||||
|
||||
return {
|
||||
true,
|
||||
QStringLiteral("已取消提醒:%1(%2)").arg(item.title, reminderDisplayTime(item.remindAt)),
|
||||
item,
|
||||
};
|
||||
}
|
||||
case ReminderCommandType::Invalid:
|
||||
return {false, command.errorMessage.isEmpty() ? QStringLiteral("没有识别到有效提醒命令。") : command.errorMessage, {}};
|
||||
}
|
||||
|
||||
return {false, QStringLiteral("没有识别到有效提醒命令。"), {}};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "ReminderTypes.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class ReminderManager;
|
||||
|
||||
struct ReminderCommandResult
|
||||
{
|
||||
bool success = false;
|
||||
QString message;
|
||||
ReminderItem item;
|
||||
};
|
||||
|
||||
class ReminderCommandHandler
|
||||
{
|
||||
public:
|
||||
static ReminderCommandResult handle(const QString &text, ReminderManager &manager);
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
#include "ReminderManager.h"
|
||||
|
||||
#include "../util/Logger.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QRandomGenerator>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr qint64 MinimumTimerIntervalMs = 1000;
|
||||
constexpr qint64 MaximumTimerIntervalMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
bool isPending(const ReminderItem &item)
|
||||
{
|
||||
return item.status == ReminderStatus::Pending;
|
||||
}
|
||||
|
||||
bool textMatchesReminder(const ReminderItem &item, const QString &query)
|
||||
{
|
||||
const QString normalizedQuery = query.trimmed();
|
||||
if (normalizedQuery.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.id.compare(normalizedQuery, Qt::CaseInsensitive) == 0
|
||||
|| item.title.contains(normalizedQuery, Qt::CaseInsensitive)
|
||||
|| item.originalText.contains(normalizedQuery, Qt::CaseInsensitive);
|
||||
}
|
||||
}
|
||||
|
||||
ReminderManager::ReminderManager()
|
||||
{
|
||||
QObject::connect(&m_timer, &QTimer::timeout, [this]() {
|
||||
processDueReminders();
|
||||
});
|
||||
m_timer.setSingleShot(true);
|
||||
load();
|
||||
}
|
||||
|
||||
void ReminderManager::start()
|
||||
{
|
||||
if (m_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_started = true;
|
||||
processDueReminders();
|
||||
}
|
||||
|
||||
void ReminderManager::setTriggeredCallback(TriggeredCallback callback)
|
||||
{
|
||||
m_triggeredCallback = std::move(callback);
|
||||
}
|
||||
|
||||
QVector<ReminderItem> ReminderManager::allReminders() const
|
||||
{
|
||||
return sortedReminders(m_items);
|
||||
}
|
||||
|
||||
QVector<ReminderItem> ReminderManager::pendingReminders() const
|
||||
{
|
||||
QVector<ReminderItem> result;
|
||||
for (const ReminderItem &item : m_items)
|
||||
{
|
||||
if (isPending(item))
|
||||
{
|
||||
result.append(item);
|
||||
}
|
||||
}
|
||||
return sortedReminders(result);
|
||||
}
|
||||
|
||||
ReminderCommand ReminderManager::parseCommand(const QString &text) const
|
||||
{
|
||||
return m_parser.parse(text);
|
||||
}
|
||||
|
||||
bool ReminderManager::createReminder(
|
||||
const QString &title,
|
||||
const QString &originalText,
|
||||
const QDateTime &remindAt,
|
||||
ReminderItem *createdItem,
|
||||
QString *errorMessage)
|
||||
{
|
||||
if (!remindAt.isValid() || remindAt <= QDateTime::currentDateTime())
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("提醒时间必须晚于当前时间。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ReminderItem item;
|
||||
item.id = nextReminderId();
|
||||
item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed();
|
||||
item.originalText = originalText;
|
||||
item.remindAt = remindAt;
|
||||
item.status = ReminderStatus::Pending;
|
||||
item.createdAt = QDateTime::currentDateTime();
|
||||
item.soundId.clear();
|
||||
|
||||
m_items.append(item);
|
||||
if (!save(errorMessage))
|
||||
{
|
||||
m_items.removeLast();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (createdItem != nullptr)
|
||||
{
|
||||
*createdItem = item;
|
||||
}
|
||||
|
||||
scheduleNextReminder();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReminderManager::cancelReminder(const QString &id, QString *errorMessage)
|
||||
{
|
||||
const QString normalizedId = id.trimmed();
|
||||
for (ReminderItem &item : m_items)
|
||||
{
|
||||
if (item.id == normalizedId && isPending(item))
|
||||
{
|
||||
item.status = ReminderStatus::Canceled;
|
||||
const bool saved = save(errorMessage);
|
||||
if (!saved)
|
||||
{
|
||||
item.status = ReminderStatus::Pending;
|
||||
}
|
||||
scheduleNextReminder();
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("没有找到可取消的提醒。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ReminderManager::cancelReminderByQuery(const QString &query, ReminderItem *canceledItem, QString *errorMessage)
|
||||
{
|
||||
QVector<int> matches;
|
||||
for (int index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
if (isPending(m_items.at(index)) && textMatchesReminder(m_items.at(index), query))
|
||||
{
|
||||
matches.append(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.isEmpty())
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("没有找到匹配的待提醒事项。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (matches.size() > 1)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("找到多条匹配提醒,请在设置页中选择具体提醒取消。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ReminderItem &item = m_items[matches.first()];
|
||||
item.status = ReminderStatus::Canceled;
|
||||
|
||||
const bool saved = save(errorMessage);
|
||||
if (!saved)
|
||||
{
|
||||
item.status = ReminderStatus::Pending;
|
||||
}
|
||||
else if (canceledItem != nullptr)
|
||||
{
|
||||
*canceledItem = item;
|
||||
}
|
||||
scheduleNextReminder();
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool ReminderManager::clearFinishedReminders(QString *errorMessage)
|
||||
{
|
||||
QVector<ReminderItem> previousItems = m_items;
|
||||
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)
|
||||
{
|
||||
m_items.removeAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_items.size() == previousItems.size())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!save(errorMessage))
|
||||
{
|
||||
m_items = previousItems;
|
||||
return false;
|
||||
}
|
||||
|
||||
scheduleNextReminder();
|
||||
return true;
|
||||
}
|
||||
|
||||
QString ReminderManager::pendingReminderSummary() const
|
||||
{
|
||||
const QVector<ReminderItem> reminders = pendingReminders();
|
||||
if (reminders.isEmpty())
|
||||
{
|
||||
return QStringLiteral("当前没有待提醒事项。");
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
lines.append(QStringLiteral("当前待提醒:"));
|
||||
for (const ReminderItem &item : reminders)
|
||||
{
|
||||
lines.append(QStringLiteral("%1:%2(%3)")
|
||||
.arg(item.id, item.title, reminderDisplayTime(item.remindAt)));
|
||||
}
|
||||
return lines.join(QChar('\n'));
|
||||
}
|
||||
|
||||
void ReminderManager::load()
|
||||
{
|
||||
QString errorMessage;
|
||||
m_items = m_store.load(&errorMessage);
|
||||
if (!errorMessage.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to load reminders: ") + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
bool ReminderManager::save(QString *errorMessage) const
|
||||
{
|
||||
return m_store.save(m_items, errorMessage);
|
||||
}
|
||||
|
||||
void ReminderManager::processDueReminders()
|
||||
{
|
||||
const QDateTime now = QDateTime::currentDateTime();
|
||||
QVector<ReminderItem> triggeredItems;
|
||||
QVector<int> triggeredIndexes;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!triggeredItems.isEmpty())
|
||||
{
|
||||
QString errorMessage;
|
||||
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;
|
||||
}
|
||||
}
|
||||
scheduleNextReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ReminderItem &item : triggeredItems)
|
||||
{
|
||||
if (m_triggeredCallback)
|
||||
{
|
||||
m_triggeredCallback(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextReminder();
|
||||
}
|
||||
|
||||
void ReminderManager::scheduleNextReminder()
|
||||
{
|
||||
m_timer.stop();
|
||||
if (!m_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QDateTime nextReminderTime;
|
||||
for (const ReminderItem &item : m_items)
|
||||
{
|
||||
if (!isPending(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextReminderTime.isValid() || item.remindAt < nextReminderTime)
|
||||
{
|
||||
nextReminderTime = item.remindAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextReminderTime.isValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 delayMs = QDateTime::currentDateTime().msecsTo(nextReminderTime);
|
||||
const int timerInterval = static_cast<int>(qBound<qint64>(MinimumTimerIntervalMs, delayMs, MaximumTimerIntervalMs));
|
||||
m_timer.start(timerInterval);
|
||||
}
|
||||
|
||||
QString ReminderManager::nextReminderId() const
|
||||
{
|
||||
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMddHHmmsszzz"));
|
||||
const quint32 randomValue = QRandomGenerator::global()->bounded(10000U);
|
||||
return QStringLiteral("reminder_%1_%2").arg(timestamp, QString::number(randomValue).rightJustified(4, QLatin1Char('0')));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "ReminderParser.h"
|
||||
#include "ReminderStore.h"
|
||||
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class ReminderManager
|
||||
{
|
||||
public:
|
||||
using TriggeredCallback = std::function<void(const ReminderItem &)>;
|
||||
|
||||
ReminderManager();
|
||||
|
||||
void start();
|
||||
void setTriggeredCallback(TriggeredCallback callback);
|
||||
|
||||
QVector<ReminderItem> allReminders() const;
|
||||
QVector<ReminderItem> pendingReminders() const;
|
||||
ReminderCommand parseCommand(const QString &text) const;
|
||||
|
||||
bool createReminder(
|
||||
const QString &title,
|
||||
const QString &originalText,
|
||||
const QDateTime &remindAt,
|
||||
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);
|
||||
QString pendingReminderSummary() const;
|
||||
|
||||
private:
|
||||
void load();
|
||||
bool save(QString *errorMessage = nullptr) const;
|
||||
void processDueReminders();
|
||||
void scheduleNextReminder();
|
||||
QString nextReminderId() const;
|
||||
|
||||
ReminderStore m_store;
|
||||
ReminderParser m_parser;
|
||||
QVector<ReminderItem> m_items;
|
||||
QTimer m_timer;
|
||||
TriggeredCallback m_triggeredCallback;
|
||||
bool m_started = false;
|
||||
};
|
||||
@@ -0,0 +1,484 @@
|
||||
#include "ReminderParser.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QRegularExpression>
|
||||
#include <QStringList>
|
||||
#include <QTime>
|
||||
|
||||
namespace
|
||||
{
|
||||
bool containsAny(const QString &text, const QStringList &keywords)
|
||||
{
|
||||
for (const QString &keyword : keywords)
|
||||
{
|
||||
if (text.contains(keyword))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int chineseDigitValue(QChar value)
|
||||
{
|
||||
if (value == QLatin1Char('0') || value == QChar(0x3007) || value == QStringLiteral("零").at(0))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (value == QStringLiteral("一").at(0))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if (value == QStringLiteral("二").at(0) || value == QStringLiteral("两").at(0))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
if (value == QStringLiteral("三").at(0))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
if (value == QStringLiteral("四").at(0))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
if (value == QStringLiteral("五").at(0))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
if (value == QStringLiteral("六").at(0))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
if (value == QStringLiteral("七").at(0))
|
||||
{
|
||||
return 7;
|
||||
}
|
||||
if (value == QStringLiteral("八").at(0))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
if (value == QStringLiteral("九").at(0))
|
||||
{
|
||||
return 9;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
int parseSmallInteger(QString value)
|
||||
{
|
||||
value = value.trimmed();
|
||||
value.remove(QStringLiteral("个"));
|
||||
if (value.isEmpty())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const int numericValue = value.toInt(&ok);
|
||||
if (ok)
|
||||
{
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
const int tenIndex = value.indexOf(QStringLiteral("十"));
|
||||
if (tenIndex >= 0)
|
||||
{
|
||||
const QString left = value.left(tenIndex);
|
||||
const QString right = value.mid(tenIndex + 1);
|
||||
const int tens = left.isEmpty() ? 1 : parseSmallInteger(left);
|
||||
const int ones = right.isEmpty() ? 0 : parseSmallInteger(right);
|
||||
if (tens < 0 || ones < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
return tens * 10 + ones;
|
||||
}
|
||||
|
||||
if (value.size() == 1)
|
||||
{
|
||||
return chineseDigitValue(value.at(0));
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString cleanedText(QString text)
|
||||
{
|
||||
return text
|
||||
.replace(QChar(0xff0c), QStringLiteral(" "))
|
||||
.replace(QChar(0x3002), QStringLiteral(" "))
|
||||
.replace(QChar(0xff1a), QStringLiteral(":"))
|
||||
.replace(QChar(0xff1b), QStringLiteral(" "))
|
||||
.trimmed();
|
||||
}
|
||||
|
||||
int adjustedHour(const QString &period, int hour)
|
||||
{
|
||||
if (hour < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (period == QStringLiteral("下午") || period == QStringLiteral("晚上"))
|
||||
{
|
||||
return hour < 12 ? hour + 12 : hour;
|
||||
}
|
||||
|
||||
if (period == QStringLiteral("中午"))
|
||||
{
|
||||
return hour < 11 ? hour + 12 : hour;
|
||||
}
|
||||
|
||||
if (period == QStringLiteral("凌晨") && hour == 12)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return hour;
|
||||
}
|
||||
|
||||
int weekdayFromText(const QString &text)
|
||||
{
|
||||
const QString normalized = text.trimmed();
|
||||
if (normalized == QStringLiteral("一") || normalized == QStringLiteral("1"))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if (normalized == QStringLiteral("二") || normalized == QStringLiteral("2"))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
if (normalized == QStringLiteral("三") || normalized == QStringLiteral("3"))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
if (normalized == QStringLiteral("四") || normalized == QStringLiteral("4"))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
if (normalized == QStringLiteral("五") || normalized == QStringLiteral("5"))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
if (normalized == QStringLiteral("六") || normalized == QStringLiteral("6"))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
if (normalized == QStringLiteral("日")
|
||||
|| normalized == QStringLiteral("天")
|
||||
|| normalized == QStringLiteral("7"))
|
||||
{
|
||||
return 7;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct ReminderDateResolution
|
||||
{
|
||||
QDate date;
|
||||
bool explicitDate = false;
|
||||
};
|
||||
|
||||
ReminderDateResolution resolveReminderDate(const QString &text, const QDate ¤tDate)
|
||||
{
|
||||
QRegularExpressionMatch match;
|
||||
|
||||
const QRegularExpression nextWeekExpression(QStringLiteral("下周\\s*([一二三四五六日天1-7])"));
|
||||
match = nextWeekExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int targetWeekday = weekdayFromText(match.captured(1));
|
||||
if (targetWeekday > 0)
|
||||
{
|
||||
const int daysToNextMonday = 8 - currentDate.dayOfWeek();
|
||||
return {currentDate.addDays(daysToNextMonday + targetWeekday - 1), true};
|
||||
}
|
||||
}
|
||||
|
||||
const QRegularExpression monthDayExpression(QStringLiteral("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*(?:日|号)?"));
|
||||
match = monthDayExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int month = match.captured(1).toInt();
|
||||
const int day = match.captured(2).toInt();
|
||||
QDate date(currentDate.year(), month, day);
|
||||
if (date.isValid() && date < currentDate)
|
||||
{
|
||||
date = date.addYears(1);
|
||||
}
|
||||
return {date, true};
|
||||
}
|
||||
|
||||
const QRegularExpression slashDateExpression(QStringLiteral("(\\d{1,2})\\s*/\\s*(\\d{1,2})"));
|
||||
match = slashDateExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int month = match.captured(1).toInt();
|
||||
const int day = match.captured(2).toInt();
|
||||
QDate date(currentDate.year(), month, day);
|
||||
if (date.isValid() && date < currentDate)
|
||||
{
|
||||
date = date.addYears(1);
|
||||
}
|
||||
return {date, true};
|
||||
}
|
||||
|
||||
if (text.contains(QStringLiteral("后天")))
|
||||
{
|
||||
return {currentDate.addDays(2), true};
|
||||
}
|
||||
|
||||
if (text.contains(QStringLiteral("明天")))
|
||||
{
|
||||
return {currentDate.addDays(1), true};
|
||||
}
|
||||
|
||||
if (text.contains(QStringLiteral("今天")))
|
||||
{
|
||||
return {currentDate, true};
|
||||
}
|
||||
|
||||
return {currentDate, false};
|
||||
}
|
||||
|
||||
QString removeFirst(const QString &text, const QString &part)
|
||||
{
|
||||
QString result = text;
|
||||
const int index = result.indexOf(part);
|
||||
if (index >= 0)
|
||||
{
|
||||
result.remove(index, part.size());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
ReminderCommand ReminderParser::parse(const QString &text, const QDateTime &now) const
|
||||
{
|
||||
const QString normalized = cleanedText(text);
|
||||
if (normalized.isEmpty())
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, {}, {}, {}, QStringLiteral("提醒内容为空。")};
|
||||
}
|
||||
|
||||
if (containsAny(normalized, {QStringLiteral("提醒列表"), QStringLiteral("查看提醒"), QStringLiteral("我的提醒"), QStringLiteral("有哪些提醒")}))
|
||||
{
|
||||
return {ReminderCommandType::List, {}, normalized};
|
||||
}
|
||||
|
||||
if (normalized.contains(QStringLiteral("取消提醒")) || normalized.startsWith(QStringLiteral("取消")))
|
||||
{
|
||||
QString query = normalized;
|
||||
query.remove(QStringLiteral("取消提醒"));
|
||||
if (query == normalized)
|
||||
{
|
||||
query.remove(QStringLiteral("取消"));
|
||||
query.remove(QStringLiteral("提醒"));
|
||||
}
|
||||
query = query.trimmed();
|
||||
if (query.isEmpty())
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, normalized, {}, {}, QStringLiteral("请说明要取消哪条提醒。")};
|
||||
}
|
||||
return {ReminderCommandType::Cancel, {}, normalized, {}, query};
|
||||
}
|
||||
|
||||
return parseCreateCommand(normalized, now);
|
||||
}
|
||||
|
||||
ReminderCommand ReminderParser::parseCreateCommand(const QString &text, const QDateTime &now) const
|
||||
{
|
||||
const QDate currentDate = now.date();
|
||||
|
||||
if (containsAny(text, {
|
||||
QStringLiteral("每天"),
|
||||
QStringLiteral("每日"),
|
||||
QStringLiteral("每周"),
|
||||
QStringLiteral("每星期"),
|
||||
QStringLiteral("每月"),
|
||||
QStringLiteral("每年"),
|
||||
QStringLiteral("工作日"),
|
||||
QStringLiteral("重复"),
|
||||
}))
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("重复提醒尚未支持,目前只能创建一次性提醒。")};
|
||||
}
|
||||
|
||||
const QRegularExpression relativeMinutesExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*分钟后"));
|
||||
QRegularExpressionMatch match = relativeMinutesExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int minutes = parseSmallInteger(match.captured(1));
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
|
||||
}
|
||||
|
||||
const QString timeExpression = match.captured(0);
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, timeExpression),
|
||||
text,
|
||||
now.addSecs(minutes * 60),
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
const QRegularExpression relativeOneAndHalfHourExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?半小时后"));
|
||||
match = relativeOneAndHalfHourExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int hours = parseSmallInteger(match.captured(1));
|
||||
if (hours < 0)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")};
|
||||
}
|
||||
|
||||
const QString timeExpression = match.captured(0);
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, timeExpression),
|
||||
text,
|
||||
now.addSecs(hours * 60 * 60 + 30 * 60),
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
if (text.contains(QStringLiteral("半小时后")))
|
||||
{
|
||||
const QString timeExpression = QStringLiteral("半小时后");
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, timeExpression),
|
||||
text,
|
||||
now.addSecs(30 * 60),
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
const QRegularExpression relativeHoursExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?小时后"));
|
||||
match = relativeHoursExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const int hours = parseSmallInteger(match.captured(1));
|
||||
if (hours <= 0)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
|
||||
}
|
||||
|
||||
const QString timeExpression = match.captured(0);
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, timeExpression),
|
||||
text,
|
||||
now.addSecs(hours * 60 * 60),
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
const ReminderDateResolution dateResolution = resolveReminderDate(text, currentDate);
|
||||
if (!dateResolution.date.isValid())
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒日期。")};
|
||||
}
|
||||
|
||||
const QRegularExpression clockExpression(QStringLiteral("(上午|早上|下午|晚上|中午|凌晨)?\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*点\\s*(?:(\\d{1,2})\\s*分?)?"));
|
||||
match = clockExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
const QString period = match.captured(1);
|
||||
int hour = adjustedHour(period, parseSmallInteger(match.captured(2)));
|
||||
const int minute = match.captured(3).isEmpty() ? 0 : match.captured(3).toInt();
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")};
|
||||
}
|
||||
|
||||
QDateTime remindAt(dateResolution.date, QTime(hour, minute));
|
||||
if (remindAt <= now)
|
||||
{
|
||||
if (dateResolution.explicitDate)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
|
||||
}
|
||||
remindAt = remindAt.addDays(1);
|
||||
}
|
||||
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, match.captured(0)),
|
||||
text,
|
||||
remindAt,
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
const QRegularExpression colonClockExpression(QStringLiteral("(明天)?\\s*(?:在)?\\s*([01]?\\d|2[0-3])\\s*[::]\\s*([0-5]\\d)"));
|
||||
match = colonClockExpression.match(text);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
QDateTime remindAt(dateResolution.date, QTime(match.captured(2).toInt(), match.captured(3).toInt()));
|
||||
if (remindAt <= now)
|
||||
{
|
||||
if (dateResolution.explicitDate)
|
||||
{
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
|
||||
}
|
||||
remindAt = remindAt.addDays(1);
|
||||
}
|
||||
|
||||
return {
|
||||
ReminderCommandType::Create,
|
||||
extractTitle(text, match.captured(0)),
|
||||
text,
|
||||
remindAt,
|
||||
{},
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到提醒时间。支持如“10分钟后提醒我喝水”“明天9点提醒我开会”。")};
|
||||
}
|
||||
|
||||
QString ReminderParser::extractTitle(QString text, const QString &timeExpression) const
|
||||
{
|
||||
text = removeFirst(text, timeExpression);
|
||||
const QStringList tokensToRemove = {
|
||||
QStringLiteral("提醒我"),
|
||||
QStringLiteral("提醒"),
|
||||
QStringLiteral("叫我"),
|
||||
QStringLiteral("到点"),
|
||||
QStringLiteral("的时候"),
|
||||
QStringLiteral("请"),
|
||||
QStringLiteral("帮我"),
|
||||
QStringLiteral("今天"),
|
||||
QStringLiteral("明天"),
|
||||
QStringLiteral("后天"),
|
||||
};
|
||||
|
||||
for (const QString &token : tokensToRemove)
|
||||
{
|
||||
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)))
|
||||
{
|
||||
text.remove(0, 1);
|
||||
text = text.trimmed();
|
||||
}
|
||||
|
||||
return text.isEmpty() ? QStringLiteral("提醒") : text;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "ReminderTypes.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
class ReminderParser
|
||||
{
|
||||
public:
|
||||
ReminderCommand parse(const QString &text, const QDateTime &now = QDateTime::currentDateTime()) const;
|
||||
|
||||
private:
|
||||
ReminderCommand parseCreateCommand(const QString &text, const QDateTime &now) const;
|
||||
QString extractTitle(QString text, const QString &timeExpression) const;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "ReminderSoundPlayer.h"
|
||||
|
||||
#include "ReminderSoundRepository.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QUrl>
|
||||
#include <QtGlobal>
|
||||
|
||||
void ReminderSoundPlayer::play(const QString &soundId, double volume)
|
||||
{
|
||||
const QString path = ReminderSoundRepository::soundPath(soundId);
|
||||
if (!QFileInfo::exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_soundEffect.stop();
|
||||
m_soundEffect.setLoopCount(1);
|
||||
m_soundEffect.setVolume(static_cast<float>(qBound(0.0, volume, 1.0)));
|
||||
m_soundEffect.setSource(QUrl::fromLocalFile(path));
|
||||
m_soundEffect.play();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QSoundEffect>
|
||||
#include <QString>
|
||||
|
||||
class ReminderSoundPlayer
|
||||
{
|
||||
public:
|
||||
void play(const QString &soundId, double volume);
|
||||
|
||||
private:
|
||||
QSoundEffect m_soundEffect;
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
#include "ReminderSoundRepository.h"
|
||||
|
||||
#include "../util/ResourcePaths.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr qint64 MaxSoundFileBytes = 5 * 1024 * 1024;
|
||||
constexpr int MaxSoundDurationSeconds = 30;
|
||||
|
||||
QString appDataPath()
|
||||
{
|
||||
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
return path.isEmpty() ? QDir::currentPath() : path;
|
||||
}
|
||||
|
||||
QString sanitizedSoundId(QString value)
|
||||
{
|
||||
value = value.trimmed().toLower();
|
||||
value.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_"));
|
||||
while (value.startsWith(QLatin1Char('.')) || value.startsWith(QLatin1Char('_')) || value.startsWith(QLatin1Char('-')))
|
||||
{
|
||||
value.remove(0, 1);
|
||||
}
|
||||
while (value.endsWith(QLatin1Char('.')) || value.endsWith(QLatin1Char('_')) || value.endsWith(QLatin1Char('-')))
|
||||
{
|
||||
value.chop(1);
|
||||
}
|
||||
return value.isEmpty() ? QStringLiteral("reminder_sound") : value;
|
||||
}
|
||||
|
||||
QString displayNameForBuiltInSound(const QString &id)
|
||||
{
|
||||
if (id == QStringLiteral("reminder_default"))
|
||||
{
|
||||
return QStringLiteral("默认提醒");
|
||||
}
|
||||
|
||||
if (id == QStringLiteral("reminder_soft"))
|
||||
{
|
||||
return QStringLiteral("柔和提醒");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
ReminderSoundInfo builtInSound(const QString &id)
|
||||
{
|
||||
return {
|
||||
id,
|
||||
displayNameForBuiltInSound(id),
|
||||
ResourcePaths::reminderSoundsRootPath() + QLatin1Char('/') + id + QStringLiteral(".wav"),
|
||||
true,
|
||||
};
|
||||
}
|
||||
|
||||
bool readChunkHeader(QDataStream &stream, QByteArray *id, quint32 *size)
|
||||
{
|
||||
char chunkId[4] = {};
|
||||
if (stream.readRawData(chunkId, 4) != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
*id = QByteArray(chunkId, 4);
|
||||
stream >> *size;
|
||||
return stream.status() == QDataStream::Ok;
|
||||
}
|
||||
}
|
||||
|
||||
QString ReminderSoundRepository::defaultSoundId()
|
||||
{
|
||||
return QStringLiteral("reminder_default");
|
||||
}
|
||||
|
||||
QVector<ReminderSoundInfo> ReminderSoundRepository::availableSounds()
|
||||
{
|
||||
QVector<ReminderSoundInfo> sounds;
|
||||
const QStringList builtInIds = {
|
||||
QStringLiteral("reminder_default"),
|
||||
QStringLiteral("reminder_soft"),
|
||||
};
|
||||
|
||||
for (const QString &id : builtInIds)
|
||||
{
|
||||
const ReminderSoundInfo info = builtInSound(id);
|
||||
if (QFileInfo::exists(info.path))
|
||||
{
|
||||
sounds.append(info);
|
||||
}
|
||||
}
|
||||
|
||||
QDir userRoot(userSoundsRootPath());
|
||||
const QFileInfoList userFiles = userRoot.entryInfoList({QStringLiteral("*.wav")}, QDir::Files, QDir::Name);
|
||||
for (const QFileInfo &fileInfo : userFiles)
|
||||
{
|
||||
const QString id = fileInfo.completeBaseName();
|
||||
if (isBuiltInSound(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sounds.append({
|
||||
id,
|
||||
id,
|
||||
QDir::cleanPath(fileInfo.absoluteFilePath()),
|
||||
false,
|
||||
});
|
||||
}
|
||||
|
||||
return sounds;
|
||||
}
|
||||
|
||||
ReminderSoundInfo ReminderSoundRepository::soundInfo(const QString &soundId)
|
||||
{
|
||||
const QString normalizedId = soundId.trimmed().isEmpty() ? defaultSoundId() : soundId.trimmed();
|
||||
for (const ReminderSoundInfo &info : availableSounds())
|
||||
{
|
||||
if (info.id == normalizedId)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
return builtInSound(defaultSoundId());
|
||||
}
|
||||
|
||||
QString ReminderSoundRepository::soundPath(const QString &soundId)
|
||||
{
|
||||
return soundInfo(soundId).path;
|
||||
}
|
||||
|
||||
QString ReminderSoundRepository::userSoundsRootPath()
|
||||
{
|
||||
return QDir(appDataPath()).filePath(QStringLiteral("sounds/reminders"));
|
||||
}
|
||||
|
||||
bool ReminderSoundRepository::isBuiltInSound(const QString &soundId)
|
||||
{
|
||||
return soundId == QStringLiteral("reminder_default")
|
||||
|| soundId == QStringLiteral("reminder_soft");
|
||||
}
|
||||
|
||||
bool ReminderSoundRepository::importSoundFile(const QString &sourcePath, QString *importedSoundId, QString *errorMessage)
|
||||
{
|
||||
const QFileInfo sourceInfo(sourcePath);
|
||||
if (!sourceInfo.exists() || !sourceInfo.isFile())
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("音效文件不存在。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sourceInfo.suffix().compare(QStringLiteral("wav"), Qt::CaseInsensitive) != 0)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("只支持导入 wav 音效。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateWaveFile(sourceInfo.absoluteFilePath(), errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir userRoot(userSoundsRootPath());
|
||||
if (!userRoot.exists() && !userRoot.mkpath(QStringLiteral(".")))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("无法创建用户音效目录。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString id = sanitizedSoundId(sourceInfo.completeBaseName());
|
||||
if (isBuiltInSound(id))
|
||||
{
|
||||
id.prepend(QStringLiteral("user_"));
|
||||
}
|
||||
|
||||
QString targetPath = userRoot.filePath(id + QStringLiteral(".wav"));
|
||||
int suffix = 2;
|
||||
while (QFileInfo::exists(targetPath))
|
||||
{
|
||||
targetPath = userRoot.filePath(id + QStringLiteral("_") + QString::number(suffix) + QStringLiteral(".wav"));
|
||||
++suffix;
|
||||
}
|
||||
|
||||
const QString finalId = QFileInfo(targetPath).completeBaseName();
|
||||
if (!QFile::copy(sourceInfo.absoluteFilePath(), targetPath))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("复制音效文件失败。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (importedSoundId != nullptr)
|
||||
{
|
||||
*importedSoundId = finalId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReminderSoundRepository::deleteUserSound(const QString &soundId, QString *errorMessage)
|
||||
{
|
||||
const QString id = soundId.trimmed();
|
||||
if (id.isEmpty() || isBuiltInSound(id))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("内置提醒音效不能删除。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString path = QDir(userSoundsRootPath()).filePath(id + QStringLiteral(".wav"));
|
||||
QFile file(path);
|
||||
if (!file.exists())
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("没有找到可删除的用户音效。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.remove())
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("删除用户音效失败。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReminderSoundRepository::validateWaveFile(const QString &path, QString *errorMessage)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("无法读取音效文件。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size() <= 44 || file.size() > MaxSoundFileBytes)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("音效文件大小不符合要求。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream stream(&file);
|
||||
stream.setByteOrder(QDataStream::LittleEndian);
|
||||
|
||||
char riff[4] = {};
|
||||
char wave[4] = {};
|
||||
quint32 riffSize = 0;
|
||||
if (stream.readRawData(riff, 4) != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
stream >> riffSize;
|
||||
if (stream.readRawData(wave, 4) != 4
|
||||
|| QByteArray(riff, 4) != QByteArray("RIFF", 4)
|
||||
|| QByteArray(wave, 4) != QByteArray("WAVE", 4))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("不是有效的 RIFF/WAVE 文件。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
quint16 audioFormat = 0;
|
||||
quint16 channels = 0;
|
||||
quint32 sampleRate = 0;
|
||||
quint16 bitsPerSample = 0;
|
||||
quint32 dataSize = 0;
|
||||
bool hasFmt = false;
|
||||
bool hasData = false;
|
||||
|
||||
while (!stream.atEnd())
|
||||
{
|
||||
QByteArray chunkId;
|
||||
quint32 chunkSize = 0;
|
||||
if (!readChunkHeader(stream, &chunkId, &chunkSize))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
const qint64 chunkStart = file.pos();
|
||||
if (chunkId == QByteArray("fmt ", 4))
|
||||
{
|
||||
quint32 byteRate = 0;
|
||||
quint16 blockAlign = 0;
|
||||
stream >> audioFormat >> channels >> sampleRate >> byteRate >> blockAlign >> bitsPerSample;
|
||||
hasFmt = true;
|
||||
}
|
||||
else if (chunkId == QByteArray("data", 4))
|
||||
{
|
||||
dataSize = chunkSize;
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
file.seek(chunkStart + chunkSize + (chunkSize % 2));
|
||||
}
|
||||
|
||||
if (!hasFmt || !hasData || audioFormat != 1 || channels == 0 || sampleRate == 0 || bitsPerSample == 0 || dataSize == 0)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("音效文件格式不受支持。请使用 PCM wav。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const double durationSeconds = static_cast<double>(dataSize) / static_cast<double>(sampleRate * channels * (bitsPerSample / 8.0));
|
||||
if (durationSeconds <= 0.0 || durationSeconds > MaxSoundDurationSeconds)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("音效时长不符合要求。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
struct ReminderSoundInfo
|
||||
{
|
||||
QString id;
|
||||
QString displayName;
|
||||
QString path;
|
||||
bool builtIn = false;
|
||||
};
|
||||
|
||||
class ReminderSoundRepository
|
||||
{
|
||||
public:
|
||||
static QString defaultSoundId();
|
||||
static QVector<ReminderSoundInfo> availableSounds();
|
||||
static ReminderSoundInfo soundInfo(const QString &soundId);
|
||||
static QString soundPath(const QString &soundId);
|
||||
static QString userSoundsRootPath();
|
||||
static bool isBuiltInSound(const QString &soundId);
|
||||
static bool importSoundFile(const QString &sourcePath, QString *importedSoundId = nullptr, QString *errorMessage = nullptr);
|
||||
static bool deleteUserSound(const QString &soundId, QString *errorMessage = nullptr);
|
||||
static bool validateWaveFile(const QString &path, QString *errorMessage = nullptr);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "ReminderStore.h"
|
||||
|
||||
#include "../util/Logger.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString ReminderStoreFileName = QStringLiteral("reminders.json");
|
||||
|
||||
QJsonObject reminderToObject(const ReminderItem &item)
|
||||
{
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("id"), item.id);
|
||||
object.insert(QStringLiteral("title"), item.title);
|
||||
object.insert(QStringLiteral("originalText"), item.originalText);
|
||||
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));
|
||||
object.insert(QStringLiteral("soundId"), item.soundId);
|
||||
return object;
|
||||
}
|
||||
|
||||
ReminderItem reminderFromObject(const QJsonObject &object)
|
||||
{
|
||||
ReminderItem item;
|
||||
item.id = object.value(QStringLiteral("id")).toString().trimmed();
|
||||
item.title = object.value(QStringLiteral("title")).toString().trimmed();
|
||||
item.originalText = object.value(QStringLiteral("originalText")).toString();
|
||||
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.soundId = object.value(QStringLiteral("soundId")).toString().trimmed();
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
QVector<ReminderItem> ReminderStore::load(QString *errorMessage) const
|
||||
{
|
||||
QFile file(storePath());
|
||||
if (!file.exists())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("无法读取提醒文件。");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
file.close();
|
||||
backupBrokenStore(storePath());
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("提醒文件损坏,已备份并使用空提醒列表。");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QVector<ReminderItem> items;
|
||||
const QJsonArray reminders = document.object().value(QStringLiteral("reminders")).toArray();
|
||||
for (const QJsonValue &value : reminders)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ReminderItem item = reminderFromObject(value.toObject());
|
||||
if (item.id.isEmpty() || !item.remindAt.isValid())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.title.isEmpty())
|
||||
{
|
||||
item.title = QStringLiteral("提醒");
|
||||
}
|
||||
|
||||
if (!item.createdAt.isValid())
|
||||
{
|
||||
item.createdAt = item.remindAt;
|
||||
}
|
||||
|
||||
items.append(item);
|
||||
}
|
||||
|
||||
return sortedReminders(items);
|
||||
}
|
||||
|
||||
bool ReminderStore::save(const QVector<ReminderItem> &items, QString *errorMessage) const
|
||||
{
|
||||
QDir directory(configDirectoryPath());
|
||||
if (!directory.exists() && !directory.mkpath(QStringLiteral(".")))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("无法创建提醒配置目录。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonArray reminders;
|
||||
for (const ReminderItem &item : items)
|
||||
{
|
||||
reminders.append(reminderToObject(item));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("reminders"), reminders);
|
||||
|
||||
QFile file(storePath());
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("无法写入提醒文件。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument document(root);
|
||||
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
|
||||
}
|
||||
|
||||
QString ReminderStore::storePath() const
|
||||
{
|
||||
return QDir(configDirectoryPath()).filePath(ReminderStoreFileName);
|
||||
}
|
||||
|
||||
QString ReminderStore::configDirectoryPath() const
|
||||
{
|
||||
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||
return path.isEmpty() ? QDir::currentPath() : path;
|
||||
}
|
||||
|
||||
void ReminderStore::backupBrokenStore(const QString &filePath) const
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QFileInfo fileInfo(filePath);
|
||||
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
|
||||
QString backupPath = fileInfo.dir().filePath(QStringLiteral("reminders.broken.") + timestamp + QStringLiteral(".json"));
|
||||
int suffix = 1;
|
||||
while (QFile::exists(backupPath))
|
||||
{
|
||||
backupPath = fileInfo.dir().filePath(
|
||||
QStringLiteral("reminders.broken.")
|
||||
+ timestamp
|
||||
+ QStringLiteral("-")
|
||||
+ QString::number(suffix)
|
||||
+ QStringLiteral(".json"));
|
||||
++suffix;
|
||||
}
|
||||
|
||||
if (!file.rename(backupPath))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to back up broken reminders file: ") + filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "ReminderTypes.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class ReminderStore
|
||||
{
|
||||
public:
|
||||
QVector<ReminderItem> load(QString *errorMessage = nullptr) const;
|
||||
bool save(const QVector<ReminderItem> &items, QString *errorMessage = nullptr) const;
|
||||
QString storePath() const;
|
||||
|
||||
private:
|
||||
QString configDirectoryPath() const;
|
||||
void backupBrokenStore(const QString &filePath) const;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "ReminderTypes.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
QString reminderStatusToString(ReminderStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case ReminderStatus::Pending:
|
||||
return QStringLiteral("pending");
|
||||
case ReminderStatus::Triggered:
|
||||
return QStringLiteral("triggered");
|
||||
case ReminderStatus::Canceled:
|
||||
return QStringLiteral("canceled");
|
||||
}
|
||||
|
||||
return QStringLiteral("pending");
|
||||
}
|
||||
|
||||
ReminderStatus reminderStatusFromString(const QString &status)
|
||||
{
|
||||
const QString normalized = status.trimmed().toLower();
|
||||
if (normalized == QStringLiteral("triggered"))
|
||||
{
|
||||
return ReminderStatus::Triggered;
|
||||
}
|
||||
|
||||
if (normalized == QStringLiteral("canceled"))
|
||||
{
|
||||
return ReminderStatus::Canceled;
|
||||
}
|
||||
|
||||
return ReminderStatus::Pending;
|
||||
}
|
||||
|
||||
QString reminderDisplayTime(const QDateTime &dateTime)
|
||||
{
|
||||
return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"));
|
||||
}
|
||||
|
||||
QVector<ReminderItem> sortedReminders(QVector<ReminderItem> reminders)
|
||||
{
|
||||
std::sort(reminders.begin(), reminders.end(), [](const ReminderItem &left, const ReminderItem &right) {
|
||||
if (left.remindAt == right.remindAt)
|
||||
{
|
||||
return left.createdAt < right.createdAt;
|
||||
}
|
||||
|
||||
return left.remindAt < right.remindAt;
|
||||
});
|
||||
return reminders;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
enum class ReminderStatus
|
||||
{
|
||||
Pending,
|
||||
Triggered,
|
||||
Canceled,
|
||||
};
|
||||
|
||||
enum class ReminderCommandType
|
||||
{
|
||||
Create,
|
||||
List,
|
||||
Cancel,
|
||||
Invalid,
|
||||
};
|
||||
|
||||
struct ReminderItem
|
||||
{
|
||||
QString id;
|
||||
QString title;
|
||||
QString originalText;
|
||||
QDateTime remindAt;
|
||||
ReminderStatus status = ReminderStatus::Pending;
|
||||
QDateTime createdAt;
|
||||
QString soundId;
|
||||
};
|
||||
|
||||
struct ReminderCommand
|
||||
{
|
||||
ReminderCommandType type = ReminderCommandType::Invalid;
|
||||
QString title;
|
||||
QString originalText;
|
||||
QDateTime remindAt;
|
||||
QString cancelQuery;
|
||||
QString errorMessage;
|
||||
};
|
||||
|
||||
QString reminderStatusToString(ReminderStatus status);
|
||||
ReminderStatus reminderStatusFromString(const QString &status);
|
||||
QString reminderDisplayTime(const QDateTime &dateTime);
|
||||
QVector<ReminderItem> sortedReminders(QVector<ReminderItem> reminders);
|
||||
@@ -62,6 +62,16 @@ void TrayController::show()
|
||||
m_trayIcon.show();
|
||||
}
|
||||
|
||||
void TrayController::showNotification(const QString &title, const QString &message)
|
||||
{
|
||||
if (!isAvailable() || !m_trayIcon.isVisible())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_trayIcon.showMessage(title, message, QSystemTrayIcon::Information, 10000);
|
||||
}
|
||||
|
||||
void TrayController::createMenu()
|
||||
{
|
||||
QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠"));
|
||||
|
||||
@@ -12,6 +12,7 @@ public:
|
||||
|
||||
bool isAvailable() const;
|
||||
void show();
|
||||
void showNotification(const QString &title, const QString &message);
|
||||
|
||||
private:
|
||||
void createMenu();
|
||||
|
||||
+115
-11
@@ -7,6 +7,11 @@
|
||||
#include "../character/CharacterPackageLoader.h"
|
||||
#include "../character/CharacterPackageRepository.h"
|
||||
#include "../config/ConfigManager.h"
|
||||
#include "../notification/NotificationDispatcher.h"
|
||||
#include "../reminder/ReminderCommandHandler.h"
|
||||
#include "../reminder/ReminderManager.h"
|
||||
#include "../reminder/ReminderSoundPlayer.h"
|
||||
#include "../reminder/ReminderSoundRepository.h"
|
||||
#include "../util/Logger.h"
|
||||
#include "ChatBubble.h"
|
||||
#include "ChatHistoryPanel.h"
|
||||
@@ -37,6 +42,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -88,6 +94,8 @@ AppConfig normalizedAppConfig(AppConfig config)
|
||||
{
|
||||
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||
}
|
||||
config.reminderSoundId = ReminderSoundRepository::soundInfo(config.reminderSoundId).id;
|
||||
config.reminderSoundVolume = qBound(0.0, config.reminderSoundVolume, 1.0);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -156,6 +164,9 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
||||
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
||||
, m_reminderManager(std::make_unique<ReminderManager>())
|
||||
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
|
||||
, m_petView(new PetView(this))
|
||||
, m_dragging(false)
|
||||
, m_alwaysOnTop(true)
|
||||
@@ -203,6 +214,13 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
return !window.isNull() && window->submitChatMessage(message);
|
||||
});
|
||||
|
||||
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
window->handleTriggeredReminder(item);
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialImage();
|
||||
}
|
||||
|
||||
@@ -321,11 +339,29 @@ void PetWindow::showBubbleMessage(const QString &message)
|
||||
void PetWindow::openSettingsDialog()
|
||||
{
|
||||
ConfigManager configManager;
|
||||
SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() {
|
||||
return isManualStateSwitchLocked();
|
||||
}, [this]() {
|
||||
clearConversation();
|
||||
}, this);
|
||||
SettingsDialog dialog(
|
||||
configManager.loadAIConfigStore(),
|
||||
currentAppConfig(),
|
||||
m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
|
||||
[this]() {
|
||||
return isManualStateSwitchLocked();
|
||||
},
|
||||
[this]() {
|
||||
clearConversation();
|
||||
},
|
||||
[this](const QString &reminderId, QString *errorMessage) {
|
||||
return m_reminderManager && m_reminderManager->cancelReminder(reminderId, errorMessage);
|
||||
},
|
||||
[this](QString *errorMessage) {
|
||||
return m_reminderManager && m_reminderManager->clearFinishedReminders(errorMessage);
|
||||
},
|
||||
[this](const QString &soundId, double volume) {
|
||||
if (m_reminderSoundPlayer)
|
||||
{
|
||||
m_reminderSoundPlayer->play(soundId, volume);
|
||||
}
|
||||
},
|
||||
this);
|
||||
centerDialogOnScreen(&dialog, this);
|
||||
if (dialog.exec() != QDialog::Accepted)
|
||||
{
|
||||
@@ -374,6 +410,14 @@ void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled)
|
||||
m_settingsFallbackInContextMenuEnabled = enabled;
|
||||
}
|
||||
|
||||
void PetWindow::setTrayNotificationCallback(std::function<void(const QString &, const QString &)> callback)
|
||||
{
|
||||
if (m_notificationDispatcher)
|
||||
{
|
||||
m_notificationDispatcher->setShowCallback(std::move(callback));
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
{
|
||||
resetBubbleAutoHideTimer();
|
||||
@@ -455,12 +499,6 @@ void PetWindow::startChat()
|
||||
|
||||
bool PetWindow::submitChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_conversationManager || m_conversationManager->isBusy())
|
||||
{
|
||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalizedMessage = message.trimmed();
|
||||
if (normalizedMessage.isEmpty())
|
||||
{
|
||||
@@ -476,6 +514,11 @@ bool PetWindow::submitChatMessage(const QString &message)
|
||||
|
||||
CommandDispatcher dispatcher;
|
||||
const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage);
|
||||
if (result.action == CommandDispatchAction::Reminder)
|
||||
{
|
||||
return handleReminderChatMessage(result.message);
|
||||
}
|
||||
|
||||
if (result.action == CommandDispatchAction::UnsupportedTool)
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
@@ -486,6 +529,57 @@ bool PetWindow::submitChatMessage(const QString &message)
|
||||
return submitAiChatMessage(result.message);
|
||||
}
|
||||
|
||||
bool PetWindow::handleReminderChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_reminderManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("提醒功能初始化失败。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const ReminderCommandResult result = ReminderCommandHandler::handle(
|
||||
message,
|
||||
*m_reminderManager);
|
||||
playState(result.success ? QStringLiteral("talk") : QStringLiteral("error"), false);
|
||||
showBubbleMessage(result.message);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
void PetWindow::handleTriggeredReminder(const ReminderItem &item)
|
||||
{
|
||||
if (m_appConfig.reminderSoundEnabled && m_reminderSoundPlayer)
|
||||
{
|
||||
m_reminderSoundPlayer->play(m_appConfig.reminderSoundId, m_appConfig.reminderSoundVolume);
|
||||
}
|
||||
|
||||
if (!isVisible())
|
||||
{
|
||||
if (m_notificationDispatcher)
|
||||
{
|
||||
m_notificationDispatcher->showReminder(QStringLiteral("定时提醒"), QStringLiteral("到时间啦:%1").arg(item.title));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_dragging)
|
||||
{
|
||||
m_deferredTriggeredReminders.append(item);
|
||||
return;
|
||||
}
|
||||
|
||||
showTriggeredReminder(item);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
bool PetWindow::submitAiChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_conversationManager || m_conversationManager->isBusy())
|
||||
@@ -814,6 +908,10 @@ void PetWindow::hideEvent(QHideEvent *event)
|
||||
void PetWindow::showEvent(QShowEvent *event)
|
||||
{
|
||||
QWidget::showEvent(event);
|
||||
if (m_reminderManager)
|
||||
{
|
||||
m_reminderManager->start();
|
||||
}
|
||||
scheduleAnimationPrewarm();
|
||||
}
|
||||
|
||||
@@ -878,6 +976,12 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
|
||||
m_dragging = false;
|
||||
playResolvedState(m_stateMachine.endDrag(), false);
|
||||
scheduleAnimationPrewarm();
|
||||
const QVector<ReminderItem> deferredReminders = m_deferredTriggeredReminders;
|
||||
m_deferredTriggeredReminders.clear();
|
||||
for (const ReminderItem &item : deferredReminders)
|
||||
{
|
||||
showTriggeredReminder(item);
|
||||
}
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "../character/CharacterPackage.h"
|
||||
#include "../character/FrameAnimator.h"
|
||||
#include "../config/AppConfig.h"
|
||||
#include "../reminder/ReminderTypes.h"
|
||||
#include "../state/PetStateMachine.h"
|
||||
|
||||
#include <QMap>
|
||||
@@ -11,9 +12,11 @@
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class QMenu;
|
||||
@@ -26,7 +29,10 @@ class ChatHistoryPanel;
|
||||
class ChatInputDialog;
|
||||
class ConversationManager;
|
||||
class ConversationStore;
|
||||
class NotificationDispatcher;
|
||||
class PetView;
|
||||
class ReminderManager;
|
||||
class ReminderSoundPlayer;
|
||||
|
||||
class PetWindow : public QWidget
|
||||
{
|
||||
@@ -39,6 +45,7 @@ public:
|
||||
void openSettingsDialog();
|
||||
void activateFromExternalInstance();
|
||||
void setSettingsFallbackInContextMenuEnabled(bool enabled);
|
||||
void setTrayNotificationCallback(std::function<void(const QString &, const QString &)> callback);
|
||||
void pauseAnimation();
|
||||
void resumeAnimation();
|
||||
void showBubbleMessage(const QString &message);
|
||||
@@ -61,6 +68,9 @@ private:
|
||||
void startChat();
|
||||
bool submitChatMessage(const QString &message);
|
||||
bool submitAiChatMessage(const QString &message);
|
||||
bool handleReminderChatMessage(const QString &message);
|
||||
void handleTriggeredReminder(const ReminderItem &item);
|
||||
void showTriggeredReminder(const ReminderItem &item);
|
||||
void clearConversation();
|
||||
void cancelActiveAIRequest();
|
||||
void showConversationHistory();
|
||||
@@ -108,6 +118,9 @@ private:
|
||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||
std::unique_ptr<ConversationStore> m_conversationStore;
|
||||
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
||||
std::unique_ptr<ReminderManager> m_reminderManager;
|
||||
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
||||
PetView *m_petView;
|
||||
QTimer m_idleBehaviorTimer;
|
||||
QTimer m_behaviorReturnTimer;
|
||||
@@ -123,6 +136,7 @@ private:
|
||||
QPoint m_dragOffset;
|
||||
QString m_streamingAssistantText;
|
||||
QStringList m_animationPrewarmQueue;
|
||||
QVector<ReminderItem> m_deferredTriggeredReminders;
|
||||
qint64 m_clipAccessSerial = 0;
|
||||
bool m_dragging;
|
||||
bool m_alwaysOnTop;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../character/CharacterPackageRepository.h"
|
||||
#include "../config/SecretStore.h"
|
||||
#include "../reminder/ReminderSoundRepository.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
@@ -17,11 +19,13 @@
|
||||
#include <QLineEdit>
|
||||
#include <QList>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QMessageBox>
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QDir>
|
||||
#include <QSignalBlocker>
|
||||
#include <QSpinBox>
|
||||
#include <QStackedWidget>
|
||||
#include <QStyle>
|
||||
@@ -60,13 +64,70 @@ QString userVisibleErrorMessage(const ChatResponse &response)
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
QString reminderStatusDisplayText(ReminderStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case ReminderStatus::Pending:
|
||||
return QStringLiteral("待提醒");
|
||||
case ReminderStatus::Triggered:
|
||||
return QStringLiteral("已触发");
|
||||
case ReminderStatus::Canceled:
|
||||
return QStringLiteral("已取消");
|
||||
}
|
||||
|
||||
return QStringLiteral("待提醒");
|
||||
}
|
||||
|
||||
bool reminderMatchesStatusFilter(const ReminderItem &item, const QString &filter)
|
||||
{
|
||||
if (filter == QStringLiteral("all"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filter == QStringLiteral("pending"))
|
||||
{
|
||||
return item.status == ReminderStatus::Pending;
|
||||
}
|
||||
|
||||
if (filter == QStringLiteral("triggered"))
|
||||
{
|
||||
return item.status == ReminderStatus::Triggered;
|
||||
}
|
||||
|
||||
if (filter == QStringLiteral("canceled"))
|
||||
{
|
||||
return item.status == ReminderStatus::Canceled;
|
||||
}
|
||||
|
||||
return item.status == ReminderStatus::Pending;
|
||||
}
|
||||
|
||||
bool hasFinishedReminders(const QVector<ReminderItem> &reminders)
|
||||
{
|
||||
for (const ReminderItem &item : reminders)
|
||||
{
|
||||
if (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDialog::SettingsDialog(
|
||||
const AIConfigStore &configStore,
|
||||
const AppConfig &appConfig,
|
||||
const QVector<ReminderItem> &reminders,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
std::function<void()> clearConversationHistoryCallback,
|
||||
std::function<bool(const QString &, QString *)> cancelReminderCallback,
|
||||
std::function<bool(QString *)> clearFinishedRemindersCallback,
|
||||
std::function<void(const QString &, double)> playReminderSoundCallback,
|
||||
QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, m_providerComboBox(new QComboBox(this))
|
||||
@@ -95,10 +156,26 @@ SettingsDialog::SettingsDialog(
|
||||
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
|
||||
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
|
||||
, m_characterStatusLabel(new QLabel(this))
|
||||
, m_reminderStatusFilterComboBox(new QComboBox(this))
|
||||
, m_reminderListWidget(new QListWidget(this))
|
||||
, m_cancelReminderButton(new QPushButton(QStringLiteral("取消选中提醒"), this))
|
||||
, m_clearFinishedRemindersButton(new QPushButton(QStringLiteral("清理历史"), this))
|
||||
, m_reminderStatusLabel(new QLabel(this))
|
||||
, m_reminderSoundEnabledCheckBox(new QCheckBox(QStringLiteral("启用提醒音效"), this))
|
||||
, m_reminderSoundVolumeSpinBox(new QSpinBox(this))
|
||||
, m_reminderSoundComboBox(new QComboBox(this))
|
||||
, m_importReminderSoundButton(new QPushButton(QStringLiteral("导入音效"), this))
|
||||
, m_deleteReminderSoundButton(new QPushButton(QStringLiteral("删除音效"), this))
|
||||
, m_testReminderSoundButton(new QPushButton(QStringLiteral("试听"), this))
|
||||
, m_reminderSoundStatusLabel(new QLabel(this))
|
||||
, m_configStore(configStore)
|
||||
, m_appConfig(appConfig)
|
||||
, m_reminders(reminders)
|
||||
, m_aiTestBlocked(std::move(aiTestBlocked))
|
||||
, m_clearConversationHistory(std::move(clearConversationHistoryCallback))
|
||||
, m_cancelReminder(std::move(cancelReminderCallback))
|
||||
, m_clearFinishedReminders(std::move(clearFinishedRemindersCallback))
|
||||
, m_playReminderSound(std::move(playReminderSoundCallback))
|
||||
{
|
||||
setWindowTitle(QStringLiteral("设置"));
|
||||
setModal(true);
|
||||
@@ -278,6 +355,75 @@ SettingsDialog::SettingsDialog(
|
||||
auto *chatPage = new QWidget(this);
|
||||
chatPage->setLayout(chatPageLayout);
|
||||
|
||||
m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_reminderStatusLabel->setWordWrap(true);
|
||||
m_reminderSoundStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_reminderSoundStatusLabel->setWordWrap(true);
|
||||
|
||||
m_reminderStatusFilterComboBox->addItem(QStringLiteral("待提醒"), QStringLiteral("pending"));
|
||||
m_reminderStatusFilterComboBox->addItem(QStringLiteral("已触发"), QStringLiteral("triggered"));
|
||||
m_reminderStatusFilterComboBox->addItem(QStringLiteral("已取消"), QStringLiteral("canceled"));
|
||||
m_reminderStatusFilterComboBox->addItem(QStringLiteral("全部"), QStringLiteral("all"));
|
||||
m_reminderListWidget->setObjectName(QStringLiteral("ReminderList"));
|
||||
m_reminderListWidget->setFrameShape(QFrame::NoFrame);
|
||||
m_reminderListWidget->setMinimumHeight(150);
|
||||
m_reminderListWidget->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_reminderSoundEnabledCheckBox->setChecked(m_appConfig.reminderSoundEnabled);
|
||||
m_reminderSoundVolumeSpinBox->setRange(0, 100);
|
||||
m_reminderSoundVolumeSpinBox->setSingleStep(5);
|
||||
m_reminderSoundVolumeSpinBox->setSuffix(QStringLiteral("%"));
|
||||
m_reminderSoundVolumeSpinBox->setValue(qRound(qBound(0.0, m_appConfig.reminderSoundVolume, 1.0) * 100.0));
|
||||
reloadReminderList();
|
||||
reloadReminderSoundList(m_appConfig.reminderSoundId);
|
||||
|
||||
auto *reminderTitleLabel = new QLabel(QStringLiteral("提醒"), this);
|
||||
reminderTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
||||
auto *reminderHintLabel = new QLabel(QStringLiteral("这里显示通过聊天创建的待触发提醒。内置提醒音效不可删除,导入音效仅支持 PCM wav。"), this);
|
||||
reminderHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
reminderHintLabel->setWordWrap(true);
|
||||
|
||||
auto *reminderListLayout = new QVBoxLayout();
|
||||
reminderListLayout->setSpacing(8);
|
||||
auto *reminderFilterLayout = new QHBoxLayout();
|
||||
reminderFilterLayout->addWidget(new QLabel(QStringLiteral("状态"), this));
|
||||
reminderFilterLayout->addWidget(m_reminderStatusFilterComboBox);
|
||||
reminderFilterLayout->addStretch();
|
||||
reminderListLayout->addLayout(reminderFilterLayout);
|
||||
reminderListLayout->addWidget(m_reminderListWidget);
|
||||
auto *reminderActionLayout = new QHBoxLayout();
|
||||
reminderActionLayout->addWidget(m_cancelReminderButton);
|
||||
reminderActionLayout->addWidget(m_clearFinishedRemindersButton);
|
||||
reminderActionLayout->addWidget(m_reminderStatusLabel, 1);
|
||||
reminderListLayout->addLayout(reminderActionLayout);
|
||||
|
||||
auto *reminderSoundFormLayout = new QFormLayout();
|
||||
reminderSoundFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
reminderSoundFormLayout->setLabelAlignment(Qt::AlignRight);
|
||||
reminderSoundFormLayout->setHorizontalSpacing(18);
|
||||
reminderSoundFormLayout->setVerticalSpacing(12);
|
||||
reminderSoundFormLayout->addRow(QString(), m_reminderSoundEnabledCheckBox);
|
||||
reminderSoundFormLayout->addRow(QStringLiteral("当前音效"), m_reminderSoundComboBox);
|
||||
reminderSoundFormLayout->addRow(QStringLiteral("音量"), m_reminderSoundVolumeSpinBox);
|
||||
|
||||
auto *reminderSoundActionLayout = new QHBoxLayout();
|
||||
reminderSoundActionLayout->addWidget(m_importReminderSoundButton);
|
||||
reminderSoundActionLayout->addWidget(m_deleteReminderSoundButton);
|
||||
reminderSoundActionLayout->addWidget(m_testReminderSoundButton);
|
||||
reminderSoundActionLayout->addWidget(m_reminderSoundStatusLabel, 1);
|
||||
|
||||
auto *reminderPageLayout = new QVBoxLayout();
|
||||
reminderPageLayout->setContentsMargins(24, 24, 24, 24);
|
||||
reminderPageLayout->setSpacing(16);
|
||||
reminderPageLayout->addWidget(reminderTitleLabel);
|
||||
reminderPageLayout->addWidget(reminderHintLabel);
|
||||
reminderPageLayout->addLayout(reminderListLayout);
|
||||
reminderPageLayout->addLayout(reminderSoundFormLayout);
|
||||
reminderPageLayout->addLayout(reminderSoundActionLayout);
|
||||
reminderPageLayout->addStretch();
|
||||
|
||||
auto *reminderPage = new QWidget(this);
|
||||
reminderPage->setLayout(reminderPageLayout);
|
||||
|
||||
m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_characterStatusLabel->setWordWrap(true);
|
||||
reloadCharacterList(m_appConfig.characterId);
|
||||
@@ -319,6 +465,7 @@ SettingsDialog::SettingsDialog(
|
||||
navigationList->setSpacing(4);
|
||||
navigationList->addItem(QStringLiteral("AI 配置"));
|
||||
navigationList->addItem(QStringLiteral("聊天"));
|
||||
navigationList->addItem(QStringLiteral("提醒"));
|
||||
navigationList->addItem(QStringLiteral("应用"));
|
||||
navigationList->addItem(QStringLiteral("角色"));
|
||||
|
||||
@@ -326,6 +473,7 @@ SettingsDialog::SettingsDialog(
|
||||
pageStack->setObjectName(QStringLiteral("SettingsPages"));
|
||||
pageStack->addWidget(aiPage);
|
||||
pageStack->addWidget(chatPage);
|
||||
pageStack->addWidget(reminderPage);
|
||||
pageStack->addWidget(appPage);
|
||||
pageStack->addWidget(characterPage);
|
||||
|
||||
@@ -372,6 +520,16 @@ SettingsDialog::SettingsDialog(
|
||||
"QListWidget#SettingsNavigation::item:selected {"
|
||||
" background: #eaf3ff; color: #175cd3; font-weight: 600;"
|
||||
"}"
|
||||
"QListWidget#ReminderList {"
|
||||
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
||||
" padding: 0px;"
|
||||
"}"
|
||||
"QListWidget#ReminderList::item {"
|
||||
" min-height: 28px; padding: 5px 8px; color: #344054;"
|
||||
"}"
|
||||
"QListWidget#ReminderList::item:selected {"
|
||||
" background: #eaf3ff; color: #175cd3;"
|
||||
"}"
|
||||
"QStackedWidget#SettingsPages {"
|
||||
" background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;"
|
||||
"}"
|
||||
@@ -405,6 +563,32 @@ SettingsDialog::SettingsDialog(
|
||||
connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||
deleteSelectedCharacter();
|
||||
});
|
||||
connect(m_reminderStatusFilterComboBox, &QComboBox::currentIndexChanged, this, [this]() {
|
||||
reloadReminderList();
|
||||
});
|
||||
connect(m_reminderListWidget, &QListWidget::currentItemChanged, this, [this]() {
|
||||
updateReminderActionButtons();
|
||||
});
|
||||
connect(m_cancelReminderButton, &QPushButton::clicked, this, [this]() {
|
||||
cancelSelectedReminder();
|
||||
});
|
||||
connect(m_clearFinishedRemindersButton, &QPushButton::clicked, this, [this]() {
|
||||
clearFinishedReminders();
|
||||
});
|
||||
connect(m_reminderSoundComboBox, &QComboBox::currentIndexChanged, this, [this]() {
|
||||
updateReminderSoundButtons();
|
||||
});
|
||||
connect(m_importReminderSoundButton, &QPushButton::clicked, this, [this]() {
|
||||
importReminderSound();
|
||||
});
|
||||
connect(m_deleteReminderSoundButton, &QPushButton::clicked, this, [this]() {
|
||||
deleteSelectedReminderSound();
|
||||
});
|
||||
connect(m_testReminderSoundButton, &QPushButton::clicked, this, [this]() {
|
||||
testSelectedReminderSound();
|
||||
});
|
||||
updateReminderActionButtons();
|
||||
updateReminderSoundButtons();
|
||||
}
|
||||
|
||||
SettingsDialog::~SettingsDialog()
|
||||
@@ -455,6 +639,13 @@ AppConfig SettingsDialog::appConfig() const
|
||||
{
|
||||
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||
}
|
||||
config.reminderSoundId = selectedReminderSoundId();
|
||||
if (config.reminderSoundId.isEmpty())
|
||||
{
|
||||
config.reminderSoundId = ReminderSoundRepository::defaultSoundId();
|
||||
}
|
||||
config.reminderSoundEnabled = m_reminderSoundEnabledCheckBox->isChecked();
|
||||
config.reminderSoundVolume = qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -762,6 +953,261 @@ void SettingsDialog::clearConversationHistory()
|
||||
m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。"));
|
||||
}
|
||||
|
||||
void SettingsDialog::reloadReminderList()
|
||||
{
|
||||
m_reminderListWidget->clear();
|
||||
|
||||
const QString filter = m_reminderStatusFilterComboBox->currentData().toString();
|
||||
const QVector<ReminderItem> reminders = sortedReminders(m_reminders);
|
||||
for (const ReminderItem &item : reminders)
|
||||
{
|
||||
if (!reminderMatchesStatusFilter(item, filter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *listItem = new QListWidgetItem(
|
||||
QStringLiteral("%1 %2 %3 %4")
|
||||
.arg(reminderDisplayTime(item.remindAt), reminderStatusDisplayText(item.status), item.title, item.originalText),
|
||||
m_reminderListWidget);
|
||||
listItem->setData(Qt::UserRole, item.id);
|
||||
listItem->setData(Qt::UserRole + 1, reminderStatusToString(item.status));
|
||||
listItem->setToolTip(item.originalText);
|
||||
}
|
||||
|
||||
if (m_reminderListWidget->count() == 0)
|
||||
{
|
||||
m_reminderStatusLabel->setText(QStringLiteral("当前筛选条件下没有提醒。"));
|
||||
}
|
||||
else
|
||||
{
|
||||
m_reminderStatusLabel->setText(QStringLiteral("共 %1 条提醒。").arg(m_reminderListWidget->count()));
|
||||
}
|
||||
updateReminderActionButtons();
|
||||
}
|
||||
|
||||
void SettingsDialog::reloadReminderSoundList(const QString &selectedSoundId)
|
||||
{
|
||||
const QString preferredSoundId = selectedSoundId.trimmed().isEmpty()
|
||||
? ReminderSoundRepository::defaultSoundId()
|
||||
: selectedSoundId.trimmed();
|
||||
|
||||
{
|
||||
const QSignalBlocker blocker(m_reminderSoundComboBox);
|
||||
m_reminderSoundComboBox->clear();
|
||||
const QVector<ReminderSoundInfo> sounds = ReminderSoundRepository::availableSounds();
|
||||
for (const ReminderSoundInfo &sound : sounds)
|
||||
{
|
||||
QString label = sound.displayName.trimmed().isEmpty() ? sound.id : sound.displayName.trimmed();
|
||||
if (label != sound.id)
|
||||
{
|
||||
label += QStringLiteral(" (") + sound.id + QStringLiteral(")");
|
||||
}
|
||||
label += sound.builtIn ? QStringLiteral(" - 内置") : QStringLiteral(" - 用户");
|
||||
m_reminderSoundComboBox->addItem(label, sound.id);
|
||||
}
|
||||
|
||||
if (m_reminderSoundComboBox->count() == 0)
|
||||
{
|
||||
m_reminderSoundComboBox->addItem(ReminderSoundRepository::defaultSoundId(), ReminderSoundRepository::defaultSoundId());
|
||||
}
|
||||
|
||||
int selectedIndex = m_reminderSoundComboBox->findData(preferredSoundId);
|
||||
if (selectedIndex < 0)
|
||||
{
|
||||
selectedIndex = m_reminderSoundComboBox->findData(ReminderSoundRepository::defaultSoundId());
|
||||
}
|
||||
m_reminderSoundComboBox->setCurrentIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}
|
||||
|
||||
updateReminderSoundButtons();
|
||||
}
|
||||
|
||||
QString SettingsDialog::selectedReminderSoundId() const
|
||||
{
|
||||
return m_reminderSoundComboBox->currentData().toString().trimmed();
|
||||
}
|
||||
|
||||
void SettingsDialog::updateReminderSoundButtons()
|
||||
{
|
||||
const QString soundId = selectedReminderSoundId();
|
||||
const bool hasSound = !soundId.isEmpty();
|
||||
m_deleteReminderSoundButton->setEnabled(hasSound && !ReminderSoundRepository::isBuiltInSound(soundId));
|
||||
m_testReminderSoundButton->setEnabled(hasSound);
|
||||
}
|
||||
|
||||
void SettingsDialog::updateReminderActionButtons()
|
||||
{
|
||||
QListWidgetItem *item = m_reminderListWidget->currentItem();
|
||||
const bool selectedPending = item != nullptr
|
||||
&& item->data(Qt::UserRole + 1).toString() == QStringLiteral("pending");
|
||||
m_cancelReminderButton->setEnabled(selectedPending);
|
||||
m_clearFinishedRemindersButton->setEnabled(hasFinishedReminders(m_reminders));
|
||||
}
|
||||
|
||||
void SettingsDialog::cancelSelectedReminder()
|
||||
{
|
||||
QListWidgetItem *item = m_reminderListWidget->currentItem();
|
||||
if (item == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QString reminderId = item->data(Qt::UserRole).toString();
|
||||
const QMessageBox::StandardButton result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("取消提醒"),
|
||||
QStringLiteral("确定要取消这条提醒吗?记录会保留为 canceled,不会物理删除。"));
|
||||
if (result != QMessageBox::Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!m_cancelReminder || !m_cancelReminder(reminderId, &errorMessage))
|
||||
{
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("取消提醒失败"),
|
||||
errorMessage.isEmpty() ? QStringLiteral("没有找到可取消的提醒。") : errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
for (ReminderItem &reminder : m_reminders)
|
||||
{
|
||||
if (reminder.id == reminderId)
|
||||
{
|
||||
reminder.status = ReminderStatus::Canceled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
reloadReminderList();
|
||||
m_reminderStatusLabel->setText(QStringLiteral("已取消提醒:%1").arg(reminderId));
|
||||
}
|
||||
|
||||
void SettingsDialog::clearFinishedReminders()
|
||||
{
|
||||
if (!hasFinishedReminders(m_reminders))
|
||||
{
|
||||
m_reminderStatusLabel->setText(QStringLiteral("没有可清理的历史提醒。"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QMessageBox::StandardButton result = QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("清理提醒历史"),
|
||||
QStringLiteral("确定要清理所有已触发和已取消的提醒记录吗?\n\n待提醒事项不会被删除。"),
|
||||
QMessageBox::Yes | QMessageBox::Cancel,
|
||||
QMessageBox::Cancel);
|
||||
if (result != QMessageBox::Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!m_clearFinishedReminders || !m_clearFinishedReminders(&errorMessage))
|
||||
{
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("清理提醒历史失败"),
|
||||
errorMessage.isEmpty() ? QStringLiteral("清理提醒历史失败。") : errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
m_reminders.removeAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
reloadReminderList();
|
||||
m_reminderStatusLabel->setText(QStringLiteral("已清理提醒历史。"));
|
||||
}
|
||||
|
||||
void SettingsDialog::importReminderSound()
|
||||
{
|
||||
const QString filePath = QFileDialog::getOpenFileName(
|
||||
this,
|
||||
QStringLiteral("导入提醒音效"),
|
||||
QString(),
|
||||
QStringLiteral("Wave 音频 (*.wav)"));
|
||||
if (filePath.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QString importedSoundId;
|
||||
QString errorMessage;
|
||||
if (!ReminderSoundRepository::importSoundFile(filePath, &importedSoundId, &errorMessage))
|
||||
{
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("导入音效失败"),
|
||||
errorMessage.isEmpty() ? QStringLiteral("导入音效失败。") : errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
reloadReminderSoundList(importedSoundId);
|
||||
m_reminderSoundStatusLabel->setText(QStringLiteral("已导入音效:%1").arg(importedSoundId));
|
||||
}
|
||||
|
||||
void SettingsDialog::deleteSelectedReminderSound()
|
||||
{
|
||||
const QString soundId = selectedReminderSoundId();
|
||||
if (soundId.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReminderSoundRepository::isBuiltInSound(soundId))
|
||||
{
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("不能删除内置音效"),
|
||||
QStringLiteral("内置提醒音效作为默认资源保留,不能在设置页删除。"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QMessageBox::StandardButton result = QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("删除提醒音效"),
|
||||
QStringLiteral("确定要删除用户音效 \"%1\" 吗?\n\n这会删除用户音效文件,操作不可恢复。").arg(soundId),
|
||||
QMessageBox::Yes | QMessageBox::Cancel,
|
||||
QMessageBox::Cancel);
|
||||
if (result != QMessageBox::Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!ReminderSoundRepository::deleteUserSound(soundId, &errorMessage))
|
||||
{
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("删除音效失败"),
|
||||
errorMessage.isEmpty() ? QStringLiteral("删除用户音效失败。") : errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
reloadReminderSoundList(ReminderSoundRepository::defaultSoundId());
|
||||
m_reminderSoundStatusLabel->setText(QStringLiteral("已删除音效:%1,当前已回退到默认音效。").arg(soundId));
|
||||
}
|
||||
|
||||
void SettingsDialog::testSelectedReminderSound()
|
||||
{
|
||||
const QString soundId = selectedReminderSoundId();
|
||||
if (soundId.isEmpty() || !m_playReminderSound)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_playReminderSound(soundId, qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0));
|
||||
m_reminderSoundStatusLabel->setText(QStringLiteral("正在试听:%1").arg(soundId));
|
||||
}
|
||||
|
||||
void SettingsDialog::reloadCharacterList(const QString &selectedCharacterId)
|
||||
{
|
||||
const QString currentSelection = selectedCharacterId.trimmed().isEmpty()
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
#include "../config/AIConfig.h"
|
||||
#include "../config/AppConfig.h"
|
||||
#include "../reminder/ReminderTypes.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QVector>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
@@ -13,6 +15,7 @@ class QComboBox;
|
||||
class QDoubleSpinBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
class LLMProvider;
|
||||
@@ -23,8 +26,12 @@ public:
|
||||
explicit SettingsDialog(
|
||||
const AIConfigStore &configStore,
|
||||
const AppConfig &appConfig,
|
||||
const QVector<ReminderItem> &reminders,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
std::function<void()> clearConversationHistoryCallback,
|
||||
std::function<bool(const QString &, QString *)> cancelReminderCallback,
|
||||
std::function<bool(QString *)> clearFinishedRemindersCallback,
|
||||
std::function<void(const QString &, double)> playReminderSoundCallback,
|
||||
QWidget *parent = nullptr);
|
||||
~SettingsDialog() override;
|
||||
|
||||
@@ -48,6 +55,16 @@ private:
|
||||
void reloadCharacterList(const QString &selectedCharacterId = {});
|
||||
void importCharacterFolder();
|
||||
void deleteSelectedCharacter();
|
||||
void reloadReminderList();
|
||||
void reloadReminderSoundList(const QString &selectedSoundId = {});
|
||||
QString selectedReminderSoundId() const;
|
||||
void updateReminderSoundButtons();
|
||||
void updateReminderActionButtons();
|
||||
void cancelSelectedReminder();
|
||||
void clearFinishedReminders();
|
||||
void importReminderSound();
|
||||
void deleteSelectedReminderSound();
|
||||
void testSelectedReminderSound();
|
||||
|
||||
QComboBox *m_providerComboBox = nullptr;
|
||||
QLineEdit *m_baseUrlEdit = nullptr;
|
||||
@@ -77,12 +94,28 @@ private:
|
||||
QPushButton *m_importCharacterButton = nullptr;
|
||||
QPushButton *m_deleteCharacterButton = nullptr;
|
||||
QLabel *m_characterStatusLabel = nullptr;
|
||||
QComboBox *m_reminderStatusFilterComboBox = nullptr;
|
||||
QListWidget *m_reminderListWidget = nullptr;
|
||||
QPushButton *m_cancelReminderButton = nullptr;
|
||||
QPushButton *m_clearFinishedRemindersButton = nullptr;
|
||||
QLabel *m_reminderStatusLabel = nullptr;
|
||||
QCheckBox *m_reminderSoundEnabledCheckBox = nullptr;
|
||||
QSpinBox *m_reminderSoundVolumeSpinBox = nullptr;
|
||||
QComboBox *m_reminderSoundComboBox = nullptr;
|
||||
QPushButton *m_importReminderSoundButton = nullptr;
|
||||
QPushButton *m_deleteReminderSoundButton = nullptr;
|
||||
QPushButton *m_testReminderSoundButton = nullptr;
|
||||
QLabel *m_reminderSoundStatusLabel = nullptr;
|
||||
AIConfigStore m_configStore;
|
||||
AIConfigStore m_acceptedConfigStore;
|
||||
AppConfig m_appConfig;
|
||||
QVector<ReminderItem> m_reminders;
|
||||
QString m_currentProvider;
|
||||
std::function<bool()> m_aiTestBlocked;
|
||||
std::function<void()> m_clearConversationHistory;
|
||||
std::function<bool(const QString &, QString *)> m_cancelReminder;
|
||||
std::function<bool(QString *)> m_clearFinishedReminders;
|
||||
std::function<void(const QString &, double)> m_playReminderSound;
|
||||
std::unique_ptr<LLMProvider> m_testProvider;
|
||||
bool m_hasAcceptedConfigStore = false;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,11 @@ QString ResourcePaths::charactersRootPath()
|
||||
return resourcePath(QStringLiteral("characters"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::reminderSoundsRootPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("sounds/reminders"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::appIconPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("icons/app_icon.ico"));
|
||||
|
||||
@@ -8,6 +8,7 @@ public:
|
||||
static QString resourcesRootPath();
|
||||
static QString resourcePath(const QString &relativePath);
|
||||
static QString charactersRootPath();
|
||||
static QString reminderSoundsRootPath();
|
||||
static QString appIconPath();
|
||||
static QString appIconSourcePngPath();
|
||||
};
|
||||
|
||||
@@ -190,6 +190,7 @@ $targetExePath = Join-Path $packageRoot "QtDesktopPet.exe"
|
||||
$resourcesRoot = Join-Path $repoRoot "resources"
|
||||
$charactersRoot = Join-Path $resourcesRoot "characters"
|
||||
$iconsRoot = Join-Path $resourcesRoot "icons"
|
||||
$soundsRoot = Join-Path $resourcesRoot "sounds"
|
||||
$licensePath = Join-Path $repoRoot "LICENSE"
|
||||
$readmePath = Join-Path $repoRoot "README.md"
|
||||
$installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss"
|
||||
@@ -197,6 +198,7 @@ $installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss"
|
||||
Assert-RequiredPath -Path $resolvedExePath -Description "QtDesktopPet.exe"
|
||||
Assert-RequiredPath -Path (Join-Path $charactersRoot "shiroko\character.json") -Description "Default character package"
|
||||
Assert-RequiredPath -Path (Join-Path $iconsRoot "app_icon.ico") -Description "Application icon"
|
||||
Assert-RequiredPath -Path (Join-Path $soundsRoot "reminders\reminder_default.wav") -Description "Default reminder sound"
|
||||
Assert-RequiredPath -Path $licensePath -Description "LICENSE"
|
||||
Assert-RequiredPath -Path $readmePath -Description "README.md"
|
||||
|
||||
@@ -211,6 +213,7 @@ New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null
|
||||
Copy-Item -LiteralPath $resolvedExePath -Destination $targetExePath -Force
|
||||
Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters")
|
||||
Copy-DirectoryFresh -Source $iconsRoot -Destination (Join-Path $packageRoot "resources\icons")
|
||||
Copy-DirectoryFresh -Source $soundsRoot -Destination (Join-Path $packageRoot "resources\sounds")
|
||||
Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENSE") -Force
|
||||
Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force
|
||||
|
||||
@@ -223,7 +226,7 @@ $manifestPath = Join-Path $packageRoot "package_manifest.txt"
|
||||
"Version: $Version",
|
||||
"CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))",
|
||||
"SourceExe: $resolvedExePath",
|
||||
"Includes: QtDesktopPet.exe, Qt runtime, resources, LICENSE, README.md",
|
||||
"Includes: QtDesktopPet.exe, Qt runtime, character/icon/sound resources, LICENSE, README.md",
|
||||
"Excludes: tools, docs, reports, build, dist, .git"
|
||||
) | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||
|
||||
|
||||
Reference in New Issue
Block a user