From 1d95ab3a96bceeb90c75350e7ff5d263c25c4408 Mon Sep 17 00:00:00 2001 From: jiao-ling <1097859252@qq.com> Date: Fri, 7 Aug 2026 00:07:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E9=87=8D=E5=81=9A=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E9=80=9A=E7=9F=A5=E9=80=90=E9=A1=B9=E5=A4=84=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- anchor.md | 6 +- tests/test_notifications.py | 163 +++++++++++++++++++++++++++- todo_app/constants.py | 2 +- todo_app/dialogs.py | 204 +++++++++++++++++++++++++++++++----- todo_app/main_window.py | 22 ++++ 6 files changed, 368 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ad92c8b..4082b14 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - 📐 任务较少或为空时,列表会在滚动条位置保留等宽 viewport gutter,使卡片左右外边距继续对称;滚动条出现后仍保持 8px 紧凑样式且不裁切卡片。 - 🪄 任务卡片在窄窗口下会从计时文本末尾省略并保留状态前缀,省略时可悬停查看完整内容;多卡片之间保留真实 8px 间距,深浅主题下的优先级与编辑/删除浮层也采用更克制、清晰的交互配色。 - ↕️ 待办列表使用 8px 紧凑滚动条;默认窄窗口下,滚动条与其右侧空白之和等于左侧留白,并随深浅主题切换颜色。 -- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口,可选择后批量完成或推迟,不再层层弹出;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。 +- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口;每条任务可直接完成、选择自己的推迟时长,或清除截止时间,批量操作仅作用于显式勾选项。通知中的“忽略”会清除截止时间并保留任务,不会把任务标记为完成或删除;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。 - ⏱️ 新增任务的默认截止时间严格取当前时间一小时后,日期会自然跨到次日,并按界面可见分钟保存。 - 🕒 截止时间改为内联日期下拉与滚轮时间控件;常见任务可直接滚轮调时,跨日期时才展开日历,无需独立确认窗口。 - ⏳ 任务延后后只编辑内容或优先级会保留新的截止时间与提醒状态;只有实际修改时间或提醒选项才会重置调度。 diff --git a/anchor.md b/anchor.md index ff56caa..82472d6 100644 --- a/anchor.md +++ b/anchor.md @@ -37,7 +37,7 @@ - `feature` → 提升次版本号。 - `bugfix` → 提升修订号。 - 仅文档与注释变更默认不触发版本号递增,除非影响发布说明或行为约定。 -- 当前约定版本:`v2.0.0`。 +- 当前约定版本:`v2.1.0`。 ## 数据约束 - 所有待办保存在项目根目录下的 `todos.json`,结构为列表,元素为字典;打包版运行时会改存至用户数据目录(Windows `%APPDATA%\TODOList`,其他平台 `~/.todolist/`)。 @@ -67,8 +67,9 @@ - 相邻任务卡片的可见外边界固定保留 8px 透明列表间距,item 高度必须与当前卡片动态高度一致且不得小于卡片最小高度;卡片、边框、计时文字和优先级标识按主题形成轻量层次,操作浮层使用不透明主题背景遮住底层计时,编辑/删除按钮默认保持中性,仅在 hover、focus 或 pressed 时分别强化主题强调与危险语义。 - 列表纵向滚动条固定为 8px 紧凑宽度,轨道透明、滑块跟随主题配色;窗口左侧外边距等于“滚动条宽度 + 滚动条右侧外边距”,当前参数为 `15px = 8px + 7px`。滚动条隐藏时,列表 viewport 在同一边界保留 8px gutter;滚动条出现时释放 gutter 给真实滚动条,使可见卡片左右外边界到主内容边界的留白始终对称,取整误差不超过 1px。仅列表向右延伸,顶部筛选和标题行仍保持 15px 右外边距;状态切换不得残留旧几何、触发横向滚动条或造成卡片裁切。 - 已完成任务只通过勾选状态、线框及配色区分,编辑按钮始终可用,由主窗口逻辑负责根据任务 ID 处理编辑请求。 -- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。批量完成或推迟只影响选中任务,并各执行一次存储与列表刷新;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,明确忽略或真正退出后才释放 Qt 对象与主题信号连接。 +- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。任务行内可直接完成、独立选择推迟时长或清除截止时间;复选框默认未选,批量完成、推迟与清除截止时间只影响显式选中任务。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。 - 推迟流程:推迟会同步更新 `snoozeUntil` 与可编辑的 `dueDate`;若原截止时间已早于推迟目标,默认截止时间自动推进到推迟目标。编辑保存按同一时刻而非 ISO 字符串判断截止时间是否变化,普通内容与优先级修改保留延后的新时间及提醒状态,只有实际修改时间或提醒偏移时才清理旧调度状态。 +- 忽略语义:通知中的“忽略”表示保留任务但清除其时间约束;主窗口将 `dueDate` 与 `snoozeUntil` 置为 `None`,将 `notifiedForReminder` 与 `notifiedForDue` 重置为 `False`,保留 `reminderOffset`、`completed` 与 `lastNotifiedAt`。无截止时间时任务不显示超时且不会触发提醒;以后重新设置截止时间时继续使用原提醒偏好。本语义不提供撤销或历史恢复。 - 截止时间编辑:新增任务的默认截止时间沿绝对时间线取本地当前时间一小时后,日期与时间来自同一目标时刻并按可见分钟保存;未改默认日期与分钟时保留该目标的 UTC 实例,避免夏令时重复小时丢失 offset。时间使用支持滚轮和上下键微调的 `QTimeEdit`,日期使用低频内联 `QDateEdit` 日历下拉。选择日期直接应用,不再创建独立日期确认窗口。编辑已有任务时,未改日期与分钟则保留原截止时间的完整精度,实际调整后秒与毫秒归零。 - 托盘行为:系统托盘菜单与最小化逻辑集中在 `main_window.py::_create_tray_icon`,调整时注意多平台兼容;点击窗口最小化按钮会直接隐藏到托盘且不显示系统气泡,提醒触发时会自动还原窗口并置顶。 @@ -78,6 +79,7 @@ - 若确认无变更,提交说明需写明“锚点已复盘,无需更新”。 ## 最近约定变更 +- 2026-08-06:feature,到期通知改为以行内逐项完成、独立推迟和清除截止时间为主路径,批量操作仅作用于默认未选的显式勾选项;“忽略”保留任务与提醒偏好但清除时间约束,版本更新至 `v2.1.0`。 - 2026-07-31:refactor,将任务卡片宽高、区域挤压优先级与详情浮层尺寸/位置收敛为不依赖 Qt 事件循环的纯函数布局模型,用户可见行为与像素结果不变;按 refactor 版本规则更新至 `v2.0.0`。 - 2026-07-31:bugfix,待办数据改为同目录临时文件 `fsync` 后原子替换,覆盖前保留单份有效备份;主文件损坏时只读降级且拒绝后续静默覆盖,版本更新至 `v1.8.1`。 - 2026-07-30:refactor(流程维护),为 pull request 与 `main` 建立 Python 3.11 / Qt offscreen 自动化测试门禁,精确锁定 PySide6 与 PyInstaller,并让 Windows 打包从依赖文件安装(应用行为不变,不触发版本号)。 diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 8d23dd2..b4b89cd 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -11,7 +11,13 @@ from PySide6.QtCore import QEvent # noqa: E402 from PySide6.QtGui import QCloseEvent # noqa: E402 -from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QLabel # noqa: E402 +from PySide6.QtWidgets import ( # noqa: E402 + QApplication, + QCheckBox, + QDialog, + QLabel, + QPushButton, +) from todo_app.dialogs import NotificationDialog # noqa: E402 from todo_app.main_window import ModernTodoAppWindow # noqa: E402 @@ -60,30 +66,110 @@ def test_actions_emit_only_selected_tasks_and_keep_remaining_rows(self) -> None: ) destroyed = [] dialog.destroyed.connect(lambda: destroyed.append(True)) + first_checkbox = dialog.findChild(QCheckBox, "notificationSelect_1") second_checkbox = dialog.findChild(QCheckBox, "notificationSelect_2") + self.assertIsNotNone(first_checkbox) self.assertIsNotNone(second_checkbox) - second_checkbox.setChecked(False) + self.assertFalse(first_checkbox.isChecked()) + self.assertFalse(second_checkbox.isChecked()) + self.assertFalse(dialog.complete_button.isEnabled()) + self.assertFalse(dialog.snooze_default_button.isEnabled()) + self.assertFalse(dialog.snooze_menu_button.isEnabled()) + self.assertFalse(dialog.ignore_button.isEnabled()) + completed: list[list[int]] = [] snoozed: list[tuple[list[int], timedelta]] = [] + ignored: list[list[int]] = [] dialog.complete_requested.connect(completed.append) dialog.snooze_requested.connect(lambda ids, duration: snoozed.append((ids, duration))) + dialog.ignore_requested.connect(ignored.append) + + dialog.complete_button.click() + dialog.snooze_default() + dialog.ignore_button.click() + + self.assertEqual(completed, []) + self.assertEqual(snoozed, []) + self.assertEqual(ignored, []) + first_checkbox.setChecked(True) + self.assertTrue(dialog.complete_button.isEnabled()) + self.assertTrue(dialog.snooze_default_button.isEnabled()) + self.assertTrue(dialog.snooze_menu_button.isEnabled()) + self.assertTrue(dialog.ignore_button.isEnabled()) dialog.complete_button.click() dialog.snooze_default() + dialog.ignore_button.click() self.assertEqual(completed, [[1]]) self.assertEqual(snoozed, [([1], timedelta(minutes=15))]) + self.assertEqual(ignored, [[1]]) dialog.show() self.app.processEvents() dialog.remove_tasks([1]) self.assertEqual(dialog.task_ids(), [2]) self.assertTrue(dialog.isVisible()) + self.assertFalse(dialog.complete_button.isEnabled()) + self.assertFalse(dialog.ignore_button.isEnabled()) dialog.remove_tasks([2]) self.app.sendPostedEvents(None, QEvent.Type.DeferredDelete) self.app.processEvents() self.assertEqual(destroyed, [True]) + def test_inline_actions_target_only_their_own_task(self) -> None: + dialog = NotificationDialog( + [(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), True)] + ) + self.addCleanup(dialog.close) + completed: list[list[int]] = [] + snoozed: list[tuple[list[int], timedelta]] = [] + ignored: list[list[int]] = [] + dialog.complete_requested.connect(completed.append) + dialog.snooze_requested.connect(lambda ids, duration: snoozed.append((ids, duration))) + dialog.ignore_requested.connect(ignored.append) + + complete_first = dialog.findChild(QPushButton, "notificationComplete_1") + snooze_second = dialog.findChild(QPushButton, "notificationSnooze_2") + ignore_first = dialog.findChild(QPushButton, "notificationIgnore_1") + self.assertIsNotNone(complete_first) + self.assertIsNotNone(snooze_second) + self.assertIsNotNone(ignore_first) + self.assertEqual( + [action.text() for action in snooze_second.menu().actions()], + ["15分钟后", "1小时后", "晚上8点", "明天上午9点"], + ) + + complete_first.click() + one_hour_action = next( + action for action in snooze_second.menu().actions() if action.text() == "1小时后" + ) + one_hour_action.trigger() + ignore_first.click() + + self.assertEqual(completed, [[1]]) + self.assertEqual(snoozed, [([2], timedelta(hours=1))]) + self.assertEqual(ignored, [[1]]) + + def test_due_text_includes_absolute_and_relative_time(self) -> None: + todo = make_todo(1, "相对时间") + todo["dueDate"] = "2026-08-06T10:00:00+00:00" + dialog = NotificationDialog([(todo, False)]) + self.addCleanup(dialog.close) + + before_due = dialog._format_due_text( + todo, + datetime(2026, 8, 6, 9, 55, tzinfo=timezone.utc), + ) + after_due = dialog._format_due_text( + todo, + datetime(2026, 8, 6, 10, 12, tzinfo=timezone.utc), + ) + + self.assertIn("截止时间:", before_due) + self.assertIn("还有 5分", before_due) + self.assertIn("已超时 12分", after_due) + def test_common_three_task_batch_expands_before_scrolling(self) -> None: dialog = NotificationDialog( [(make_todo(todo_id, f"任务{todo_id}"), True) for todo_id in (1, 2, 3)] @@ -117,6 +203,7 @@ def __init__(self, requests, parent=None) -> None: self.visible = False self.complete_requested = FakeSignal() self.snooze_requested = FakeSignal() + self.ignore_requested = FakeSignal() self.finished = FakeSignal() if isinstance(requests, list): self.add_or_update_tasks(requests) @@ -387,6 +474,78 @@ def test_batch_snooze_keeps_unselected_tasks_unchanged(self) -> None: self.assertEqual(save_mock.call_count, 1) self.assertEqual(window.update_list_widget.call_count, 1) + def test_tasks_can_be_snoozed_independently_with_different_durations(self) -> None: + first = make_todo(1, "任务1") + second = make_todo(2, "任务2") + with ( + patch("todo_app.main_window.load_todos", return_value=[]), + patch("todo_app.main_window.save_todos") as save_mock, + patch("todo_app.main_window.NotificationDialog", FakeNotificationDialog), + ): + window = ModernTodoAppWindow() + window.master_timer.stop() + self.addCleanup(self._close_window, window) + window.todos = [first, second] + window.update_list_widget = MagicMock() + dialog = FakeNotificationDialog([(first, True), (second, True)], window) + window._notification_dialog = dialog + + window._handle_notification_snooze([1], timedelta(minutes=15)) + first_after_snooze = first.copy() + window._handle_notification_snooze([2], timedelta(hours=1)) + + self.assertEqual(first, first_after_snooze) + first_until = datetime.fromisoformat(first["snoozeUntil"]) + second_until = datetime.fromisoformat(second["snoozeUntil"]) + self.assertGreater(second_until - first_until, timedelta(minutes=44)) + self.assertEqual(dialog.task_ids(), []) + self.assertEqual(save_mock.call_count, 2) + self.assertEqual(window.update_list_widget.call_count, 2) + + def test_ignore_clears_schedule_but_preserves_task_and_preference(self) -> None: + task = make_todo(1, "保留但清除时间") + task.update( + { + "reminderOffset": 900, + "snoozeUntil": "2026-08-06T12:00:00+00:00", + "notifiedForReminder": True, + "notifiedForDue": True, + "lastNotifiedAt": "2026-08-06T10:00:00+00:00", + } + ) + with ( + patch("todo_app.main_window.load_todos", return_value=[]), + patch("todo_app.main_window.save_todos") as save_mock, + patch("todo_app.main_window.NotificationDialog", FakeNotificationDialog), + ): + window = ModernTodoAppWindow() + window.master_timer.stop() + self.addCleanup(self._close_window, window) + window.todos = [task] + window.update_list_widget = MagicMock() + window._show_notification_batch = MagicMock() + dialog = FakeNotificationDialog([(task, True)], window) + window._notification_dialog = dialog + + window._handle_notification_ignore([1]) + + self.assertIsNone(task["dueDate"]) + self.assertIsNone(task["snoozeUntil"]) + self.assertFalse(task["notifiedForReminder"]) + self.assertFalse(task["notifiedForDue"]) + self.assertEqual(task["reminderOffset"], 900) + self.assertFalse(task["completed"]) + self.assertEqual(task["lastNotifiedAt"], "2026-08-06T10:00:00+00:00") + self.assertEqual(window.todos, [task]) + self.assertEqual(dialog.task_ids(), []) + self.assertEqual(save_mock.call_count, 1) + self.assertEqual(window.update_list_widget.call_count, 1) + + window.tick_update() + + window._show_notification_batch.assert_not_called() + self.assertEqual(save_mock.call_count, 1) + def _close_window(self, window: ModernTodoAppWindow) -> None: window.master_timer.stop() window._quitting_app = True diff --git a/todo_app/constants.py b/todo_app/constants.py index 6bff65b..a89e346 100644 --- a/todo_app/constants.py +++ b/todo_app/constants.py @@ -48,7 +48,7 @@ class ThemeColors: # --- 基本信息 --- APP_NAME = "桌面待办事项 v1" -APP_VERSION = "2.0.0" +APP_VERSION = "2.1.0" # --- 文件资源 --- APP_ICON_PATH = "assets/icons/app_icon.svg" diff --git a/todo_app/dialogs.py b/todo_app/dialogs.py index fa0c373..f6fbeb5 100644 --- a/todo_app/dialogs.py +++ b/todo_app/dialogs.py @@ -43,6 +43,7 @@ class NotificationDialog(QDialog): complete_requested = Signal(list) snooze_requested = Signal(list, object) + ignore_requested = Signal(list) def __init__(self, requests: list[tuple[dict, bool]], parent=None): super().__init__(parent) @@ -82,8 +83,10 @@ def _build_ui(self, parent=None) -> None: button_layout = QHBoxLayout() self.complete_button = QPushButton(get_icon("", "✓"), "完成选中") self.complete_button.clicked.connect(self._emit_complete_requested) - self.dismiss_button = QPushButton("忽略全部") - self.dismiss_button.clicked.connect(self.reject) + self.ignore_button = QPushButton("清除所选截止时间") + self.ignore_button.setObjectName("batchIgnoreButton") + self.ignore_button.setToolTip("保留所选任务并清除截止时间,不会标记为完成") + self.ignore_button.clicked.connect(self._emit_ignore_requested) snooze_widget = QWidget() snooze_layout = QHBoxLayout(snooze_widget) @@ -92,18 +95,15 @@ def _build_ui(self, parent=None) -> None: self.snooze_default_button = QPushButton(get_icon("", "⏰"), " 选中推迟15分钟") self.snooze_default_button.setObjectName("snoozeDefaultButton") - self.snooze_default_button.clicked.connect(self.snooze_default) + self.snooze_default_button.clicked.connect(lambda _checked=False: self.snooze_default()) self.snooze_menu_button = QPushButton("▼") self.snooze_menu_button.setObjectName("snoozeMenuButton") self.snooze_menu_button.setFixedWidth(30) - snooze_menu = QMenu(self) - snooze_menu.addAction("15分钟后", self.snooze_default) - snooze_menu.addAction("1小时后", self.snooze_1_hour) - snooze_menu.addAction("晚上8点", self.snooze_8pm) - snooze_menu.addAction("明天上午9点", self.snooze_tomorrow_9am) - self.snooze_menu_button.setMenu(snooze_menu) + self.snooze_menu_button.setMenu( + self._build_snooze_menu(parent=self.snooze_menu_button) + ) snooze_layout.addWidget(self.snooze_default_button) snooze_layout.addWidget(self.snooze_menu_button) @@ -113,9 +113,17 @@ def _build_ui(self, parent=None) -> None: QSpacerItem(20, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) ) button_layout.addWidget(snooze_widget) - button_layout.addWidget(self.dismiss_button) + button_layout.addWidget(self.ignore_button) layout.addLayout(button_layout) + self._batch_action_buttons = ( + self.complete_button, + self.snooze_default_button, + self.snooze_menu_button, + self.ignore_button, + ) + self._update_batch_actions_state() + def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: for todo_item, is_due in requests: todo_id = int(todo_item["id"]) @@ -134,8 +142,11 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: checkbox = QCheckBox() checkbox.setObjectName(f"notificationSelect_{todo_id}") - checkbox.setChecked(True) + checkbox.setChecked(False) checkbox.setToolTip("选择此任务进行批量处理") + checkbox.toggled.connect( + lambda _checked=False: self._update_batch_actions_state() + ) row_layout.addWidget(checkbox) content_layout = QVBoxLayout() @@ -154,6 +165,39 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: content_layout.addWidget(status_label) row_layout.addLayout(content_layout, 1) + action_layout = QHBoxLayout() + action_layout.setContentsMargins(0, 0, 0, 0) + action_layout.setSpacing(4) + + complete_action = QPushButton("完成") + complete_action.setObjectName(f"notificationComplete_{todo_id}") + complete_action.setProperty("notificationRowAction", True) + complete_action.setToolTip("仅将此任务标记为完成") + complete_action.clicked.connect( + lambda _checked=False, task_id=todo_id: self.complete_requested.emit([task_id]) + ) + + snooze_action = QPushButton("推迟") + snooze_action.setObjectName(f"notificationSnooze_{todo_id}") + snooze_action.setProperty("notificationRowAction", True) + snooze_action.setProperty("notificationSnoozeAction", True) + snooze_action.setToolTip("仅为此任务选择推迟时长") + snooze_action.setMenu(self._build_snooze_menu([todo_id], snooze_action)) + + ignore_action = QPushButton("清除截止时间") + ignore_action.setObjectName(f"notificationIgnore_{todo_id}") + ignore_action.setProperty("notificationRowAction", True) + ignore_action.setProperty("notificationIgnoreAction", True) + ignore_action.setToolTip("保留此任务并清除截止时间,不会标记为完成") + ignore_action.clicked.connect( + lambda _checked=False, task_id=todo_id: self.ignore_requested.emit([task_id]) + ) + + action_layout.addWidget(complete_action) + action_layout.addWidget(snooze_action) + action_layout.addWidget(ignore_action) + row_layout.addLayout(action_layout) + self.tasks_layout.addWidget(row_widget) self._task_rows[todo_id] = { "todo_item": todo_item, @@ -163,10 +207,14 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: "text_label": text_label, "detail_label": detail_label, "status_label": status_label, + "complete_action": complete_action, + "snooze_action": snooze_action, + "ignore_action": ignore_action, } self._update_task_row(todo_id) self._update_title() + self._update_batch_actions_state() self._apply_palette(self._palette) self._adjust_size_and_position() @@ -177,7 +225,9 @@ def _update_task_row(self, todo_id: int) -> None: row["detail_label"].setText(self._format_due_text(todo_item)) row["status_label"].setText("已到期" if row["is_due"] else "提前提醒") - def _format_due_text(self, todo_item: dict) -> str: + def _format_due_text( + self, todo_item: dict, current_time_utc: Optional[datetime] = None + ) -> str: due_date_str = todo_item.get("dueDate") if not due_date_str: return "未设置截止时间" @@ -185,7 +235,46 @@ def _format_due_text(self, todo_item: dict) -> str: due_date = datetime.fromisoformat(due_date_str.replace("Z", "+00:00")) except ValueError: return "截止时间格式错误" - return f"截止时间: {due_date.astimezone().strftime('%Y-%m-%d %H:%M')}" + if due_date.tzinfo is None: + due_date = due_date.astimezone() + if current_time_utc is None: + current_time_utc = datetime.now(timezone.utc) + elif current_time_utc.tzinfo is None: + current_time_utc = current_time_utc.replace(tzinfo=timezone.utc) + else: + current_time_utc = current_time_utc.astimezone(timezone.utc) + + diff = due_date.astimezone(timezone.utc) - current_time_utc + duration_text = self._format_relative_duration(diff) + if diff.total_seconds() < 0: + relative_text = f"已超时 {duration_text}" + elif diff.total_seconds() > 0: + relative_text = f"还有 {duration_text}" + else: + relative_text = "刚刚到期" + absolute_text = due_date.astimezone().strftime("%Y-%m-%d %H:%M") + return f"截止时间: {absolute_text} · {relative_text}" + + @staticmethod + def _format_relative_duration(diff: timedelta) -> str: + """沿用主列表计时的单位、取整与最多两段显示语义。""" + + effective_diff = abs(diff) + days = effective_diff.days + secs_in_day = effective_diff.seconds + hours = secs_in_day // 3600 + minutes = (secs_in_day % 3600) // 60 + seconds = secs_in_day % 60 + parts = [] + if days > 0: + parts.append(f"{days}天") + if hours > 0: + parts.append(f"{hours}时") + if minutes > 0 and days == 0: + parts.append(f"{minutes}分") + if not parts and effective_diff.total_seconds() > 0: + parts.append(f"{seconds}秒") + return " ".join(parts[:2]) if parts else "刚刚" def _update_title(self) -> None: self.title_label.setText(f"{len(self._task_rows)} 个任务需要处理") @@ -212,6 +301,11 @@ def selected_task_ids(self) -> list[int]: if row["checkbox"].isChecked() ] + def _update_batch_actions_state(self) -> None: + enabled = bool(self.selected_task_ids()) + for button in getattr(self, "_batch_action_buttons", ()): + button.setEnabled(enabled) + def remove_tasks(self, task_ids: list[int]) -> None: for todo_id in task_ids: row = self._task_rows.pop(int(todo_id), None) @@ -220,6 +314,7 @@ def remove_tasks(self, task_ids: list[int]) -> None: self.tasks_layout.removeWidget(row["widget"]) row["widget"].deleteLater() self._update_title() + self._update_batch_actions_state() self._adjust_size_and_position() if not self._task_rows: self.close() @@ -229,6 +324,11 @@ def _emit_complete_requested(self) -> None: if selected_ids: self.complete_requested.emit(selected_ids) + def _emit_ignore_requested(self) -> None: + selected_ids = self.selected_task_ids() + if selected_ids: + self.ignore_requested.emit(selected_ids) + def _apply_palette(self, palette: ThemeColors) -> None: self._palette = palette self.setStyleSheet( @@ -251,6 +351,29 @@ def _apply_palette(self, palette: ThemeColors) -> None: padding: 8px 12px; border-radius: 4px; font-size: 10pt; }} QPushButton:hover {{ background-color: {palette.accent_hover}; }} + QPushButton:disabled {{ + background-color: {palette.card_border}; color: {palette.text_secondary}; + }} + QPushButton[notificationRowAction="true"] {{ + padding: 5px 8px; font-size: 9pt; + }} + QPushButton[notificationSnoozeAction="true"] {{ + background-color: {palette.priority_medium}; + }} + QPushButton[notificationSnoozeAction="true"]:hover {{ + background-color: {palette.due_warning}; + }} + QPushButton[notificationIgnoreAction="true"], QPushButton#batchIgnoreButton {{ + background-color: {palette.due_critical}; + }} + QMenu {{ + background-color: {palette.background}; color: {palette.text_primary}; + border: 1px solid {palette.card_border}; padding: 4px; + }} + QMenu::item {{ padding: 6px 16px; }} + QMenu::item:selected {{ + background-color: {palette.accent}; color: {palette.inverse_text}; + }} QPushButton#snoozeDefaultButton, QPushButton#snoozeMenuButton {{ background-color: {palette.priority_medium}; color: {palette.inverse_text}; @@ -273,6 +396,11 @@ def _apply_palette(self, palette: ThemeColors) -> None: padding-right: 6px; }} QPushButton#snoozeMenuButton::menu-indicator {{ image: none; }} + QPushButton#snoozeDefaultButton:disabled, + QPushButton#snoozeMenuButton:disabled, + QPushButton#batchIgnoreButton:disabled {{ + background-color: {palette.card_border}; color: {palette.text_secondary}; + }} """ ) self.title_label.setStyleSheet( @@ -291,24 +419,50 @@ def _apply_palette(self, palette: ThemeColors) -> None: def _on_theme_changed(self, palette: ThemeColors) -> None: self._apply_palette(palette) - def _emit_snooze_requested(self, duration: timedelta) -> None: - selected_ids = self.selected_task_ids() - if selected_ids: - self.snooze_requested.emit(selected_ids, duration) + def _build_snooze_menu( + self, + todo_ids: Optional[list[int]] = None, + parent: Optional[QWidget] = None, + ) -> QMenu: + menu = QMenu(parent or self) + menu.addAction( + "15分钟后", + lambda _checked=False: self.snooze_default(todo_ids), + ) + menu.addAction( + "1小时后", + lambda _checked=False: self.snooze_1_hour(todo_ids), + ) + menu.addAction( + "晚上8点", + lambda _checked=False: self.snooze_8pm(todo_ids), + ) + menu.addAction( + "明天上午9点", + lambda _checked=False: self.snooze_tomorrow_9am(todo_ids), + ) + return menu + + def _emit_snooze_requested( + self, duration: timedelta, todo_ids: Optional[list[int]] = None + ) -> None: + requested_ids = self.selected_task_ids() if todo_ids is None else todo_ids + if requested_ids: + self.snooze_requested.emit(requested_ids, duration) - def snooze_default(self) -> None: - self._emit_snooze_requested(timedelta(minutes=15)) + def snooze_default(self, todo_ids: Optional[list[int]] = None) -> None: + self._emit_snooze_requested(timedelta(minutes=15), todo_ids) - def snooze_1_hour(self) -> None: - self._emit_snooze_requested(timedelta(hours=1)) + def snooze_1_hour(self, todo_ids: Optional[list[int]] = None) -> None: + self._emit_snooze_requested(timedelta(hours=1), todo_ids) - def snooze_tomorrow_9am(self) -> None: + def snooze_tomorrow_9am(self, todo_ids: Optional[list[int]] = None) -> None: now = datetime.now().astimezone() tomorrow_date = now.date() + timedelta(days=1) target_dt = datetime.combine(tomorrow_date, time(9, 0), tzinfo=now.tzinfo) - self._emit_snooze_requested(target_dt - now) + self._emit_snooze_requested(target_dt - now, todo_ids) - def snooze_8pm(self) -> None: + def snooze_8pm(self, todo_ids: Optional[list[int]] = None) -> None: now = datetime.now().astimezone() target_dt = datetime.combine(now.date(), time(20, 0), tzinfo=now.tzinfo) if target_dt <= now: @@ -317,7 +471,7 @@ def snooze_8pm(self) -> None: time(20, 0), tzinfo=now.tzinfo, ) - self._emit_snooze_requested(target_dt - now) + self._emit_snooze_requested(target_dt - now, todo_ids) class TaskEditDialog(QDialog): diff --git a/todo_app/main_window.py b/todo_app/main_window.py index 7d4df4e..8191c4f 100644 --- a/todo_app/main_window.py +++ b/todo_app/main_window.py @@ -608,6 +608,7 @@ def _show_notification_batch(self, requests: list[tuple[dict, bool]]) -> None: self._notification_dialog = dialog dialog.complete_requested.connect(self._handle_notification_complete) dialog.snooze_requested.connect(self._handle_notification_snooze) + dialog.ignore_requested.connect(self._handle_notification_ignore) dialog.finished.connect(self._on_notification_dialog_finished) dialog.show() else: @@ -656,6 +657,27 @@ def _handle_notification_snooze( save_todos(self.todos) self.update_list_widget() + def _handle_notification_ignore(self, todo_ids: list[int]) -> None: + requested_ids = {int(todo_id) for todo_id in todo_ids} + changed = False + for todo in self.todos: + if todo.get("id") not in requested_ids: + continue + updated_fields = { + "dueDate": None, + "snoozeUntil": None, + "notifiedForReminder": False, + "notifiedForDue": False, + } + if any(todo.get(key) != value for key, value in updated_fields.items()): + todo.update(updated_fields) + changed = True + + self._remove_notification_tasks(list(requested_ids)) + if changed: + save_todos(self.todos) + self.update_list_widget() + def _remove_notification_tasks(self, todo_ids: list[int]) -> None: if self._notification_dialog is not None: self._notification_dialog.remove_tasks(todo_ids) From e48f2ceb1749d60bc7d27acf15ade51bf22ffd52 Mon Sep 17 00:00:00 2001 From: jiao-ling <1097859252@qq.com> Date: Fri, 7 Aug 2026 00:22:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=8C=81=E7=BB=AD=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=9B=B8=E5=AF=B9=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_notifications.py | 22 ++++++++++++++++++++++ todo_app/dialogs.py | 13 ++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index b4b89cd..76792f0 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -170,6 +170,28 @@ def test_due_text_includes_absolute_and_relative_time(self) -> None: self.assertIn("还有 5分", before_due) self.assertIn("已超时 12分", after_due) + def test_relative_time_timer_refreshes_all_open_rows(self) -> None: + dialog = NotificationDialog( + [(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), False)] + ) + self.addCleanup(dialog.close) + self.assertTrue(dialog._relative_time_timer.isActive()) + self.assertEqual(dialog._relative_time_timer.interval(), 1000) + + with patch.object( + dialog, + "_format_due_text", + side_effect=["第一项已刷新", "第二项已刷新"], + ) as formatter: + dialog._relative_time_timer.timeout.emit() + + self.assertEqual(dialog._task_rows[1]["detail_label"].text(), "第一项已刷新") + self.assertEqual(dialog._task_rows[2]["detail_label"].text(), "第二项已刷新") + self.assertEqual(formatter.call_count, 2) + first_now = formatter.call_args_list[0].args[1] + second_now = formatter.call_args_list[1].args[1] + self.assertIs(first_now, second_now) + def test_common_three_task_batch_expands_before_scrolling(self) -> None: dialog = NotificationDialog( [(make_todo(todo_id, f"任务{todo_id}"), True) for todo_id in (1, 2, 3)] diff --git a/todo_app/dialogs.py b/todo_app/dialogs.py index f6fbeb5..8d5da4f 100644 --- a/todo_app/dialogs.py +++ b/todo_app/dialogs.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, time, timezone from typing import Optional -from PySide6.QtCore import QDateTime, QTime, Qt, Signal, Slot +from PySide6.QtCore import QDateTime, QTime, QTimer, Qt, Signal, Slot from PySide6.QtWidgets import ( QCheckBox, QDateEdit, @@ -54,6 +54,10 @@ def __init__(self, requests: list[tuple[dict, bool]], parent=None): self._build_ui(parent) self.add_or_update_tasks(requests) self._apply_palette(self._palette) + self._relative_time_timer = QTimer(self) + self._relative_time_timer.setInterval(1000) + self._relative_time_timer.timeout.connect(self._refresh_relative_times) + self._relative_time_timer.start() def _build_ui(self, parent=None) -> None: self.setWindowTitle("任务提醒") @@ -225,6 +229,13 @@ def _update_task_row(self, todo_id: int) -> None: row["detail_label"].setText(self._format_due_text(todo_item)) row["status_label"].setText("已到期" if row["is_due"] else "提前提醒") + def _refresh_relative_times(self) -> None: + current_time_utc = datetime.now(timezone.utc) + for row in self._task_rows.values(): + row["detail_label"].setText( + self._format_due_text(row["todo_item"], current_time_utc) + ) + def _format_due_text( self, todo_item: dict, current_time_utc: Optional[datetime] = None ) -> str: From b82516952fbec122b9d10a49f3c9d2ed9609e767 Mon Sep 17 00:00:00 2001 From: jiao-ling <1097859252@qq.com> Date: Fri, 7 Aug 2026 02:19:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E7=B2=BE=E7=AE=80=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E9=80=9A=E7=9F=A5=E4=B8=BA=E8=A1=8C=E5=86=85=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- anchor.md | 4 +- tests/test_notifications.py | 57 ++++----------- todo_app/dialogs.py | 136 ++++-------------------------------- 4 files changed, 30 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 4082b14..2b28cb2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - 📐 任务较少或为空时,列表会在滚动条位置保留等宽 viewport gutter,使卡片左右外边距继续对称;滚动条出现后仍保持 8px 紧凑样式且不裁切卡片。 - 🪄 任务卡片在窄窗口下会从计时文本末尾省略并保留状态前缀,省略时可悬停查看完整内容;多卡片之间保留真实 8px 间距,深浅主题下的优先级与编辑/删除浮层也采用更克制、清晰的交互配色。 - ↕️ 待办列表使用 8px 紧凑滚动条;默认窄窗口下,滚动条与其右侧空白之和等于左侧留白,并随深浅主题切换颜色。 -- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口;每条任务可直接完成、选择自己的推迟时长,或清除截止时间,批量操作仅作用于显式勾选项。通知中的“忽略”会清除截止时间并保留任务,不会把任务标记为完成或删除;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。 +- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口;每条任务只在自己的行内提供“完成”“推迟”“忽略”,没有复选框或底部批量操作。“忽略”会清除该任务的截止时间并保留任务,不会把任务标记为完成或删除;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。 - ⏱️ 新增任务的默认截止时间严格取当前时间一小时后,日期会自然跨到次日,并按界面可见分钟保存。 - 🕒 截止时间改为内联日期下拉与滚轮时间控件;常见任务可直接滚轮调时,跨日期时才展开日历,无需独立确认窗口。 - ⏳ 任务延后后只编辑内容或优先级会保留新的截止时间与提醒状态;只有实际修改时间或提醒选项才会重置调度。 diff --git a/anchor.md b/anchor.md index 82472d6..fb74b58 100644 --- a/anchor.md +++ b/anchor.md @@ -67,7 +67,7 @@ - 相邻任务卡片的可见外边界固定保留 8px 透明列表间距,item 高度必须与当前卡片动态高度一致且不得小于卡片最小高度;卡片、边框、计时文字和优先级标识按主题形成轻量层次,操作浮层使用不透明主题背景遮住底层计时,编辑/删除按钮默认保持中性,仅在 hover、focus 或 pressed 时分别强化主题强调与危险语义。 - 列表纵向滚动条固定为 8px 紧凑宽度,轨道透明、滑块跟随主题配色;窗口左侧外边距等于“滚动条宽度 + 滚动条右侧外边距”,当前参数为 `15px = 8px + 7px`。滚动条隐藏时,列表 viewport 在同一边界保留 8px gutter;滚动条出现时释放 gutter 给真实滚动条,使可见卡片左右外边界到主内容边界的留白始终对称,取整误差不超过 1px。仅列表向右延伸,顶部筛选和标题行仍保持 15px 右外边距;状态切换不得残留旧几何、触发横向滚动条或造成卡片裁切。 - 已完成任务只通过勾选状态、线框及配色区分,编辑按钮始终可用,由主窗口逻辑负责根据任务 ID 处理编辑请求。 -- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。任务行内可直接完成、独立选择推迟时长或清除截止时间;复选框默认未选,批量完成、推迟与清除截止时间只影响显式选中任务。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。 +- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟”“忽略”处置,其中推迟独立选择时长,“忽略”清除时间约束。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。 - 推迟流程:推迟会同步更新 `snoozeUntil` 与可编辑的 `dueDate`;若原截止时间已早于推迟目标,默认截止时间自动推进到推迟目标。编辑保存按同一时刻而非 ISO 字符串判断截止时间是否变化,普通内容与优先级修改保留延后的新时间及提醒状态,只有实际修改时间或提醒偏移时才清理旧调度状态。 - 忽略语义:通知中的“忽略”表示保留任务但清除其时间约束;主窗口将 `dueDate` 与 `snoozeUntil` 置为 `None`,将 `notifiedForReminder` 与 `notifiedForDue` 重置为 `False`,保留 `reminderOffset`、`completed` 与 `lastNotifiedAt`。无截止时间时任务不显示超时且不会触发提醒;以后重新设置截止时间时继续使用原提醒偏好。本语义不提供撤销或历史恢复。 - 截止时间编辑:新增任务的默认截止时间沿绝对时间线取本地当前时间一小时后,日期与时间来自同一目标时刻并按可见分钟保存;未改默认日期与分钟时保留该目标的 UTC 实例,避免夏令时重复小时丢失 offset。时间使用支持滚轮和上下键微调的 `QTimeEdit`,日期使用低频内联 `QDateEdit` 日历下拉。选择日期直接应用,不再创建独立日期确认窗口。编辑已有任务时,未改日期与分钟则保留原截止时间的完整精度,实际调整后秒与毫秒归零。 @@ -79,7 +79,7 @@ - 若确认无变更,提交说明需写明“锚点已复盘,无需更新”。 ## 最近约定变更 -- 2026-08-06:feature,到期通知改为以行内逐项完成、独立推迟和清除截止时间为主路径,批量操作仅作用于默认未选的显式勾选项;“忽略”保留任务与提醒偏好但清除时间约束,版本更新至 `v2.1.0`。 +- 2026-08-07:feature,到期通知只保留行内逐项“完成”“推迟”“忽略”,移除复选框与底部批量操作;“忽略”保留任务与提醒偏好但清除时间约束,版本更新至 `v2.1.0`。 - 2026-07-31:refactor,将任务卡片宽高、区域挤压优先级与详情浮层尺寸/位置收敛为不依赖 Qt 事件循环的纯函数布局模型,用户可见行为与像素结果不变;按 refactor 版本规则更新至 `v2.0.0`。 - 2026-07-31:bugfix,待办数据改为同目录临时文件 `fsync` 后原子替换,覆盖前保留单份有效备份;主文件损坏时只读降级且拒绝后续静默覆盖,版本更新至 `v1.8.1`。 - 2026-07-30:refactor(流程维护),为 pull request 与 `main` 建立 Python 3.11 / Qt offscreen 自动化测试门禁,精确锁定 PySide6 与 PyInstaller,并让 Windows 打包从依赖文件安装(应用行为不变,不触发版本号)。 diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 76792f0..4c18143 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -60,58 +60,25 @@ def test_tasks_are_aggregated_deduplicated_and_upgraded(self) -> None: self.assertIsNotNone(status_label) self.assertEqual(status_label.text(), "已到期") - def test_actions_emit_only_selected_tasks_and_keep_remaining_rows(self) -> None: + def test_dialog_has_only_inline_actions_and_keeps_remaining_rows(self) -> None: dialog = NotificationDialog( [(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), True)] ) destroyed = [] dialog.destroyed.connect(lambda: destroyed.append(True)) - first_checkbox = dialog.findChild(QCheckBox, "notificationSelect_1") - second_checkbox = dialog.findChild(QCheckBox, "notificationSelect_2") - self.assertIsNotNone(first_checkbox) - self.assertIsNotNone(second_checkbox) - self.assertFalse(first_checkbox.isChecked()) - self.assertFalse(second_checkbox.isChecked()) - self.assertFalse(dialog.complete_button.isEnabled()) - self.assertFalse(dialog.snooze_default_button.isEnabled()) - self.assertFalse(dialog.snooze_menu_button.isEnabled()) - self.assertFalse(dialog.ignore_button.isEnabled()) - - completed: list[list[int]] = [] - snoozed: list[tuple[list[int], timedelta]] = [] - ignored: list[list[int]] = [] - dialog.complete_requested.connect(completed.append) - dialog.snooze_requested.connect(lambda ids, duration: snoozed.append((ids, duration))) - dialog.ignore_requested.connect(ignored.append) - - dialog.complete_button.click() - dialog.snooze_default() - dialog.ignore_button.click() - - self.assertEqual(completed, []) - self.assertEqual(snoozed, []) - self.assertEqual(ignored, []) - - first_checkbox.setChecked(True) - self.assertTrue(dialog.complete_button.isEnabled()) - self.assertTrue(dialog.snooze_default_button.isEnabled()) - self.assertTrue(dialog.snooze_menu_button.isEnabled()) - self.assertTrue(dialog.ignore_button.isEnabled()) - dialog.complete_button.click() - dialog.snooze_default() - dialog.ignore_button.click() - - self.assertEqual(completed, [[1]]) - self.assertEqual(snoozed, [([1], timedelta(minutes=15))]) - self.assertEqual(ignored, [[1]]) + self.assertEqual(dialog.layout().count(), 2) + self.assertEqual(dialog.findChildren(QCheckBox), []) + self.assertFalse(hasattr(dialog, "complete_button")) + self.assertFalse(hasattr(dialog, "snooze_default_button")) + self.assertFalse(hasattr(dialog, "snooze_menu_button")) + self.assertFalse(hasattr(dialog, "ignore_button")) + self.assertIsNone(dialog.findChild(QPushButton, "batchIgnoreButton")) dialog.show() self.app.processEvents() dialog.remove_tasks([1]) self.assertEqual(dialog.task_ids(), [2]) self.assertTrue(dialog.isVisible()) - self.assertFalse(dialog.complete_button.isEnabled()) - self.assertFalse(dialog.ignore_button.isEnabled()) dialog.remove_tasks([2]) self.app.sendPostedEvents(None, QEvent.Type.DeferredDelete) self.app.processEvents() @@ -135,6 +102,8 @@ def test_inline_actions_target_only_their_own_task(self) -> None: self.assertIsNotNone(complete_first) self.assertIsNotNone(snooze_second) self.assertIsNotNone(ignore_first) + self.assertEqual(ignore_first.text(), "忽略") + self.assertIn("清除截止时间", ignore_first.toolTip()) self.assertEqual( [action.text() for action in snooze_second.menu().actions()], ["15分钟后", "1小时后", "晚上8点", "明天上午9点"], @@ -192,7 +161,7 @@ def test_relative_time_timer_refreshes_all_open_rows(self) -> None: second_now = formatter.call_args_list[1].args[1] self.assertIs(first_now, second_now) - def test_common_three_task_batch_expands_before_scrolling(self) -> None: + def test_common_three_task_dialog_expands_before_scrolling(self) -> None: dialog = NotificationDialog( [(make_todo(todo_id, f"任务{todo_id}"), True) for todo_id in (1, 2, 3)] ) @@ -442,7 +411,7 @@ def test_tray_menu_hides_and_restores_notification_batch(self) -> None: self.assertEqual(dialog.show_count, 2) self.assertTrue(dialog.visible) - def test_batch_actions_update_only_requested_tasks_once(self) -> None: + def test_requested_actions_update_only_requested_tasks_once(self) -> None: tasks = [make_todo(todo_id, f"任务{todo_id}") for todo_id in (1, 2, 3)] FakeNotificationDialog.instances = [] with ( @@ -470,7 +439,7 @@ def test_batch_actions_update_only_requested_tasks_once(self) -> None: window.toggle_complete_todo(3) self.assertEqual(dialog.task_ids(), []) - def test_batch_snooze_keeps_unselected_tasks_unchanged(self) -> None: + def test_snooze_handler_keeps_other_tasks_unchanged(self) -> None: first = make_todo(1, "任务1") second = make_todo(2, "任务2") first_before = first.copy() diff --git a/todo_app/dialogs.py b/todo_app/dialogs.py index 8d5da4f..5d4e81b 100644 --- a/todo_app/dialogs.py +++ b/todo_app/dialogs.py @@ -6,7 +6,6 @@ from PySide6.QtCore import QDateTime, QTime, QTimer, Qt, Signal, Slot from PySide6.QtWidgets import ( - QCheckBox, QDateEdit, QDialog, QDialogButtonBox, @@ -16,7 +15,6 @@ QPushButton, QScrollArea, QSpacerItem, - QSizePolicy, QVBoxLayout, QWidget, QMenu, @@ -51,7 +49,7 @@ def __init__(self, requests: list[tuple[dict, bool]], parent=None): self._theme_manager = get_theme_manager() self._palette: ThemeColors = self._theme_manager.current_palette self._theme_manager.theme_changed.connect(self._on_theme_changed) - self._build_ui(parent) + self._build_ui() self.add_or_update_tasks(requests) self._apply_palette(self._palette) self._relative_time_timer = QTimer(self) @@ -59,7 +57,7 @@ def __init__(self, requests: list[tuple[dict, bool]], parent=None): self._relative_time_timer.timeout.connect(self._refresh_relative_times) self._relative_time_timer.start() - def _build_ui(self, parent=None) -> None: + def _build_ui(self) -> None: self.setWindowTitle("任务提醒") self.setWindowIcon(get_icon(APP_ICON_PATH, "🔔")) self.setMinimumWidth(460) @@ -84,50 +82,6 @@ def _build_ui(self, parent=None) -> None: self.tasks_scroll.setWidget(self.tasks_container) layout.addWidget(self.tasks_scroll) - button_layout = QHBoxLayout() - self.complete_button = QPushButton(get_icon("", "✓"), "完成选中") - self.complete_button.clicked.connect(self._emit_complete_requested) - self.ignore_button = QPushButton("清除所选截止时间") - self.ignore_button.setObjectName("batchIgnoreButton") - self.ignore_button.setToolTip("保留所选任务并清除截止时间,不会标记为完成") - self.ignore_button.clicked.connect(self._emit_ignore_requested) - - snooze_widget = QWidget() - snooze_layout = QHBoxLayout(snooze_widget) - snooze_layout.setContentsMargins(0, 0, 0, 0) - snooze_layout.setSpacing(1) - - self.snooze_default_button = QPushButton(get_icon("", "⏰"), " 选中推迟15分钟") - self.snooze_default_button.setObjectName("snoozeDefaultButton") - self.snooze_default_button.clicked.connect(lambda _checked=False: self.snooze_default()) - - self.snooze_menu_button = QPushButton("▼") - self.snooze_menu_button.setObjectName("snoozeMenuButton") - self.snooze_menu_button.setFixedWidth(30) - - self.snooze_menu_button.setMenu( - self._build_snooze_menu(parent=self.snooze_menu_button) - ) - - snooze_layout.addWidget(self.snooze_default_button) - snooze_layout.addWidget(self.snooze_menu_button) - - button_layout.addWidget(self.complete_button) - button_layout.addSpacerItem( - QSpacerItem(20, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - ) - button_layout.addWidget(snooze_widget) - button_layout.addWidget(self.ignore_button) - layout.addLayout(button_layout) - - self._batch_action_buttons = ( - self.complete_button, - self.snooze_default_button, - self.snooze_menu_button, - self.ignore_button, - ) - self._update_batch_actions_state() - def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: for todo_item, is_due in requests: todo_id = int(todo_item["id"]) @@ -144,15 +98,6 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: row_layout.setContentsMargins(10, 8, 10, 8) row_layout.setSpacing(10) - checkbox = QCheckBox() - checkbox.setObjectName(f"notificationSelect_{todo_id}") - checkbox.setChecked(False) - checkbox.setToolTip("选择此任务进行批量处理") - checkbox.toggled.connect( - lambda _checked=False: self._update_batch_actions_state() - ) - row_layout.addWidget(checkbox) - content_layout = QVBoxLayout() content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(3) @@ -188,11 +133,13 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: snooze_action.setToolTip("仅为此任务选择推迟时长") snooze_action.setMenu(self._build_snooze_menu([todo_id], snooze_action)) - ignore_action = QPushButton("清除截止时间") + ignore_action = QPushButton("忽略") ignore_action.setObjectName(f"notificationIgnore_{todo_id}") ignore_action.setProperty("notificationRowAction", True) ignore_action.setProperty("notificationIgnoreAction", True) - ignore_action.setToolTip("保留此任务并清除截止时间,不会标记为完成") + ignore_action.setToolTip( + "保留此任务并清除截止时间;不会标记为完成,重新设置截止时间前不再提醒" + ) ignore_action.clicked.connect( lambda _checked=False, task_id=todo_id: self.ignore_requested.emit([task_id]) ) @@ -207,7 +154,6 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: "todo_item": todo_item, "is_due": is_due, "widget": row_widget, - "checkbox": checkbox, "text_label": text_label, "detail_label": detail_label, "status_label": status_label, @@ -218,7 +164,6 @@ def add_or_update_tasks(self, requests: list[tuple[dict, bool]]) -> None: self._update_task_row(todo_id) self._update_title() - self._update_batch_actions_state() self._apply_palette(self._palette) self._adjust_size_and_position() @@ -305,18 +250,6 @@ def _adjust_size_and_position(self) -> None: def task_ids(self) -> list[int]: return list(self._task_rows) - def selected_task_ids(self) -> list[int]: - return [ - todo_id - for todo_id, row in self._task_rows.items() - if row["checkbox"].isChecked() - ] - - def _update_batch_actions_state(self) -> None: - enabled = bool(self.selected_task_ids()) - for button in getattr(self, "_batch_action_buttons", ()): - button.setEnabled(enabled) - def remove_tasks(self, task_ids: list[int]) -> None: for todo_id in task_ids: row = self._task_rows.pop(int(todo_id), None) @@ -325,21 +258,10 @@ def remove_tasks(self, task_ids: list[int]) -> None: self.tasks_layout.removeWidget(row["widget"]) row["widget"].deleteLater() self._update_title() - self._update_batch_actions_state() self._adjust_size_and_position() if not self._task_rows: self.close() - def _emit_complete_requested(self) -> None: - selected_ids = self.selected_task_ids() - if selected_ids: - self.complete_requested.emit(selected_ids) - - def _emit_ignore_requested(self) -> None: - selected_ids = self.selected_task_ids() - if selected_ids: - self.ignore_requested.emit(selected_ids) - def _apply_palette(self, palette: ThemeColors) -> None: self._palette = palette self.setStyleSheet( @@ -356,7 +278,6 @@ def _apply_palette(self, palette: ThemeColors) -> None: border-radius: 6px; }} QScrollArea {{ background-color: transparent; border: none; }} - QCheckBox {{ color: {palette.text_primary}; spacing: 8px; }} QPushButton {{ background-color: {palette.accent}; color: {palette.inverse_text}; border: none; padding: 8px 12px; border-radius: 4px; font-size: 10pt; @@ -374,7 +295,7 @@ def _apply_palette(self, palette: ThemeColors) -> None: QPushButton[notificationSnoozeAction="true"]:hover {{ background-color: {palette.due_warning}; }} - QPushButton[notificationIgnoreAction="true"], QPushButton#batchIgnoreButton {{ + QPushButton[notificationIgnoreAction="true"] {{ background-color: {palette.due_critical}; }} QMenu {{ @@ -385,33 +306,6 @@ def _apply_palette(self, palette: ThemeColors) -> None: QMenu::item:selected {{ background-color: {palette.accent}; color: {palette.inverse_text}; }} - QPushButton#snoozeDefaultButton, QPushButton#snoozeMenuButton {{ - background-color: {palette.priority_medium}; - color: {palette.inverse_text}; - padding-top: 8px; - padding-bottom: 8px; - }} - QPushButton#snoozeDefaultButton:hover, QPushButton#snoozeMenuButton:hover {{ - background-color: {palette.due_warning}; - }} - QPushButton#snoozeDefaultButton {{ - border-top-right-radius: 0px; - border-bottom-right-radius: 0px; - padding-left: 12px; - padding-right: 12px; - }} - QPushButton#snoozeMenuButton {{ - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; - padding-left: 6px; - padding-right: 6px; - }} - QPushButton#snoozeMenuButton::menu-indicator {{ image: none; }} - QPushButton#snoozeDefaultButton:disabled, - QPushButton#snoozeMenuButton:disabled, - QPushButton#batchIgnoreButton:disabled {{ - background-color: {palette.card_border}; color: {palette.text_secondary}; - }} """ ) self.title_label.setStyleSheet( @@ -432,7 +326,7 @@ def _on_theme_changed(self, palette: ThemeColors) -> None: def _build_snooze_menu( self, - todo_ids: Optional[list[int]] = None, + todo_ids: list[int], parent: Optional[QWidget] = None, ) -> QMenu: menu = QMenu(parent or self) @@ -455,25 +349,23 @@ def _build_snooze_menu( return menu def _emit_snooze_requested( - self, duration: timedelta, todo_ids: Optional[list[int]] = None + self, duration: timedelta, todo_ids: list[int] ) -> None: - requested_ids = self.selected_task_ids() if todo_ids is None else todo_ids - if requested_ids: - self.snooze_requested.emit(requested_ids, duration) + self.snooze_requested.emit(todo_ids, duration) - def snooze_default(self, todo_ids: Optional[list[int]] = None) -> None: + def snooze_default(self, todo_ids: list[int]) -> None: self._emit_snooze_requested(timedelta(minutes=15), todo_ids) - def snooze_1_hour(self, todo_ids: Optional[list[int]] = None) -> None: + def snooze_1_hour(self, todo_ids: list[int]) -> None: self._emit_snooze_requested(timedelta(hours=1), todo_ids) - def snooze_tomorrow_9am(self, todo_ids: Optional[list[int]] = None) -> None: + def snooze_tomorrow_9am(self, todo_ids: list[int]) -> None: now = datetime.now().astimezone() tomorrow_date = now.date() + timedelta(days=1) target_dt = datetime.combine(tomorrow_date, time(9, 0), tzinfo=now.tzinfo) self._emit_snooze_requested(target_dt - now, todo_ids) - def snooze_8pm(self, todo_ids: Optional[list[int]] = None) -> None: + def snooze_8pm(self, todo_ids: list[int]) -> None: now = datetime.now().astimezone() target_dt = datetime.combine(now.date(), time(20, 0), tzinfo=now.tzinfo) if target_dt <= now: