diff --git a/CONTEXT.md b/CONTEXT.md index bdb7f3de..31a43d09 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -223,7 +223,7 @@ _Avoid_: 归档时机(「归档」专指 Archived Logbook) _Avoid_: 待归档、已完成未归档 **Log Completed(移入已了结)**: -把账号所有 Unlogged Item 一次移入 Logbook 的命令:把 `loggedThrough` 推进到当前时刻。入口:`⇧⌘Y`(可改绑)、任务 / 项目右键菜单、Logbook 页顶部按钮(手机端的入口);立即模式下全部隐藏。执行后 toast 可撤销,撤销把水位线退回执行前的值(水位线唯一的回退)。 +把账号所有 Unlogged Item 一次移入 Logbook 的命令:把 `loggedThrough` 推进到当前时刻。入口:`⇧⌘Y`(可改绑)、任务 / 项目右键菜单、Logbook 页顶部按钮(手机端的入口);立即模式下全部隐藏。执行后可撤销(Undo),撤销把水位线退回执行前的值(水位线唯一的回退)。 _Avoid_: 归档已完成、清理已完成 **Trash(废纸篓)**: @@ -265,7 +265,7 @@ _Avoid_: Selection(键盘专属)、编辑模式、批量选择 _Avoid_: FAB(实现名,可用于代码)、加号按钮、浮动按钮 **Undo(撤销)**: -撤销最近一步编辑(对齐 Things 3):手机端摇一摇弹出「撤销 …?」确认,确认即撤销;可连续撤销更早的步骤,不支持重做。一步(Undo Step)= 两次用户输入(按下、点击、按键)之间发起的全部编辑,所以一个动作内部的多次写入(完成并派生下一次重复实例、多选批量改期、整组拖拽)一起撤销。撤销把这一步改过的字段写回原值(之后又被改过的字段不动),新建的条目被删除,删除的 Subtask 按原内容重建;回写是普通编辑,照常同步到各设备。无法还原的物理删除(清空 Trash、删除区域 / 标签 / Heading / 附件、转换为项目)清空撤销历史。撤销历史只在本机内存里,重启或登出即清空;通知上的动作、其他设备同步来的编辑不进撤销历史。目前只有手机端提供入口(摇一摇)。 +撤销最近一步编辑(对齐 Things 3):桌面端与 Web 按 ⌘Z / Ctrl+Z 直接撤销(焦点在输入框里时是原生文字撤销),手机端摇一摇弹出「撤销 …?」确认,确认即撤销;可连续撤销更早的步骤,不支持重做。一步(Undo Step)= 两次用户输入(按下、点击、按键)之间发起的全部编辑,所以一个动作内部的多次写入(完成并派生下一次重复实例、多选批量改期、整组拖拽)一起撤销。撤销把这一步改过的字段写回原值(之后又被改过的字段不动),新建的条目被删除,删除的 Subtask 按原内容重建,Log Completed 退回水位线;回写是普通编辑,照常同步到各设备。无法还原的物理删除(清空 Trash、删除区域 / 标签 / Heading / 附件、转换为项目)清空撤销历史。撤销历史只在本机内存里,重启或登出即清空;通知上的动作、其他设备同步来的编辑不进撤销历史。桌面端每个窗口、Web 每个标签页各有一份撤销历史。 _Avoid_: 回滚、历史版本(不是数据版本管理)、Redo(不支持) **Sidebar Drop(拖到侧边栏)**: diff --git a/docs/adr/0024-undo-recorded-at-engine-writes.md b/docs/adr/0024-undo-recorded-at-engine-writes.md index 2d75a826..22d8dcca 100644 --- a/docs/adr/0024-undo-recorded-at-engine-writes.md +++ b/docs/adr/0024-undo-recorded-at-engine-writes.md @@ -51,6 +51,9 @@ group drag. - Writes that bypass the wrapper, such as the reminder coordinator (notification actions) and sync merges, never become undo steps. A new non-UI writer must use the raw Engine for the same reason. -- History lives in memory only and has no Redo. Today only the Android shell - attaches it (shake to undo). Desktop and web can attach the same wrapper - later for ⌘Z. +- History lives in memory only and has no Redo. Every shell attaches it: shake + to undo on Android, ⌘Z on desktop and web. Each desktop window and each web + tab keeps its own history, attached to that window's `TabEngine` or Engine. +- A write outside the Engine (Log Completed advances the `loggedThrough` + account preference) brings its own inverse through `UndoHistory.record` and + joins the current step like any Engine write. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index 34280421..4cc70769 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -74,7 +74,7 @@ Grouped View(今天/随时/某天按项目/区域分组)补充规则: 取消与完成同型:Logbook 中 ⌘K 撤销了结(已完成的撤销完成,已取消的撤销取消)。 -Log Completed 只在移入时机为「每天」或「手动」时生效(立即模式下没有留在列表里的已了结条目),作用于整个账号,执行后 toast 可撤销;规则见 CONTEXT.md「Log Completed」与 ADR 0022。任务 / 项目右键菜单与 Logbook 页顶部按钮同样提供。Web 的 Ctrl+Shift+Y 被浏览器占用,降级为 Alt 系。 +Log Completed 只在移入时机为「每天」或「手动」时生效(立即模式下没有留在列表里的已了结条目),作用于整个账号,执行后可用 ⌘Z 撤销;规则见 CONTEXT.md「Log Completed」与 ADR 0022。任务 / 项目右键菜单与 Logbook 页顶部按钮同样提供。Web 的 Ctrl+Shift+Y 被浏览器占用,降级为 Alt 系。 ### 标签 @@ -92,6 +92,14 @@ Log Completed 只在移入时机为「每天」或「手动」时生效(立即 作用于选中的任务行和项目行(组头、Heading 不算),多选时按显示顺序逐个复制;Trash 中不响应。副本紧跟来源(任务在列表中、项目在侧边栏中),完成后选中副本;Logbook 中复制出的任务是未完成的,落回原列表,选中不变。规则见 CONTEXT.md「Duplicate」。右键菜单与项目页「…」菜单同样提供「复制」(项目页复制后打开副本);触控设备在左滑多选工具栏的「更多」里复制勾选的任务,复制后退出多选。Web 的 Ctrl+D 是浏览器「加入书签」,可以拦截,与桌面同键。 +### 撤销 + +| 动作 | macOS 桌面 | Windows 桌面 | Web | +|---|---|---|---| +| 撤销最近一步 | ⌘Z | Ctrl+Z | Ctrl+Z | + +直接撤销,不弹确认(对齐 Things Mac;手机端是摇一摇加确认);可连续撤销更早的步骤,不支持重做。焦点在输入框里时是原生文字撤销;有弹层(卡片、菜单、对话框)打开时不响应。每个窗口 / 标签页各有一份撤销历史。规则见 CONTEXT.md「Undo」与 ADR 0024。Web 的 Ctrl+Z 可以拦截,与桌面同键。 + ### 剪贴板 | 动作 | macOS 桌面 | Windows 桌面 | Web | diff --git a/packages/api/src/hooks/useLogging.ts b/packages/api/src/hooks/useLogging.ts index 345818af..b190cd4d 100644 --- a/packages/api/src/hooks/useLogging.ts +++ b/packages/api/src/hooks/useLogging.ts @@ -11,6 +11,7 @@ import { updatePreferences } from '@/api/users.api'; import { hydrateFromServer, usePreferencesStore } from '@/stores/preferences.store'; import { currentLegacyDateTimeZone, currentTimeZone } from '@/utils/date'; import { currentLogging } from '@/utils/logging'; +import { undoHistory } from '../undo/undo-history'; import type { QueryCacheFacade } from '../engine/live-queries'; import { refreshAfterWrite, useQueryCache } from './cache-patches'; import { useCalendarDay } from './useCalendarDay'; @@ -43,10 +44,10 @@ export interface LoggingActions { /** 切换移入时机;水位线按 loggedThroughAfterModeChange 推进。 */ setLoggingMode: (mode: LoggingMode) => Promise; /** - * Log Completed:水位线推进到现在,所有 Unlogged Item 移入 Logbook。返回 - * 撤销函数(把水位线写回执行前的值);立即模式下无操作,返回 null。 + * Log Completed:水位线推进到现在,所有 Unlogged Item 移入 Logbook。记入 + * 撤销历史,撤销即把水位线写回执行前的值;立即模式下无操作。 */ - logCompleted: () => Promise<(() => Promise) | null>; + logCompleted: () => Promise; } export function useLoggingActions(): LoggingActions { @@ -67,10 +68,12 @@ export function useLoggingActions(): LoggingActions { ); const logCompleted = useCallback(async () => { const before = currentLogging(); - if (before.mode === 'IMMEDIATE') return null; - await persistLogging({ ...before, loggedThrough: new Date().toISOString() }, cache); - return () => - persistLogging({ ...currentLogging(), loggedThrough: before.loggedThrough }, cache); + if (before.mode === 'IMMEDIATE') return; + await undoHistory.record('logCompleted', async () => { + await persistLogging({ ...before, loggedThrough: new Date().toISOString() }, cache); + return () => + persistLogging({ ...currentLogging(), loggedThrough: before.loggedThrough }, cache); + }); }, [cache]); return { setLoggingMode, logCompleted }; } diff --git a/packages/api/src/i18n/locales/en/auth.json b/packages/api/src/i18n/locales/en/auth.json index 34561640..4590bafc 100644 --- a/packages/api/src/i18n/locales/en/auth.json +++ b/packages/api/src/i18n/locales/en/auth.json @@ -27,8 +27,6 @@ "changePassword": "Change Password", "currentPassword": "Current Password", "newPassword": "New Password", - "profileSaved": "Profile saved", - "passwordSaved": "Password changed", "passwordChangeFailed": "Failed to change password", "sessionExpired": "Session expired, please log in again", "sessionSaveFailed": "Could not save your session. Check local storage and try again.", diff --git a/packages/api/src/i18n/locales/en/calendar.json b/packages/api/src/i18n/locales/en/calendar.json index b4d29b61..69f0f192 100644 --- a/packages/api/src/i18n/locales/en/calendar.json +++ b/packages/api/src/i18n/locales/en/calendar.json @@ -16,7 +16,6 @@ "subscriptionNamePlaceholder": "Defaults to the calendar's own name", "addSubscription": "Add", "addingSubscription": "Fetching calendar…", - "subscriptionAdded": "Added calendar \"{{name}}\"", "noSubscriptions": "No calendars subscribed yet", "subscriptionEnabled": "Show events from this calendar", "subscriptionColor": "Color", diff --git a/packages/api/src/i18n/locales/en/common.json b/packages/api/src/i18n/locales/en/common.json index 99414ce2..0385c9eb 100644 --- a/packages/api/src/i18n/locales/en/common.json +++ b/packages/api/src/i18n/locales/en/common.json @@ -41,7 +41,6 @@ "emptyTrashConfirmTitle": "Empty Trash?", "emptyTrashConfirmDescription": "This permanently deletes all tasks and projects in Trash. This cannot be undone.", "emptyTrashConfirmAction": "Empty", - "emptyTrashSuccess": "Trash emptied", "emptyTrashFailed": "Failed to empty trash", "add": "New", "settings": "Settings", @@ -74,6 +73,7 @@ "undoActionEdit": "Edit", "undoActionReorder": "Reorder", "undoActionChange": "Change", + "undoActionLogCompleted": "Log Completed", "windowMinimize": "Minimize", "windowMaximize": "Maximize", "windowRestore": "Restore Down", diff --git a/packages/api/src/i18n/locales/en/project.json b/packages/api/src/i18n/locales/en/project.json index 7dbb9298..d0ccdf4a 100644 --- a/packages/api/src/i18n/locales/en/project.json +++ b/packages/api/src/i18n/locales/en/project.json @@ -24,9 +24,7 @@ "moveHeading": "Move", "moveHeadingSearchPlaceholder": "Search projects…", "moveHeadingNoResults": "No matching projects", - "moveHeadingSuccess": "Moved to {{project}}", "moveHeadingFailed": "Failed to move heading", - "convertSuccess": "Converted to project", "convertFailed": "Failed to convert to project", "notePlaceholder": "Note…", "settled": "Settled", @@ -34,9 +32,7 @@ "unarchive": "Unarchive", "archiveHeading": "Archive heading", "unarchiveHeading": "Unarchive heading", - "archiveSuccess": "Archived", "archiveFailed": "Failed to archive", - "unarchiveSuccess": "Unarchived", "unarchiveFailed": "Failed to unarchive", "archivedHeadingCount": "{{count}} archived headings", "laterProjects": "Later Projects", diff --git a/packages/api/src/i18n/locales/en/review.json b/packages/api/src/i18n/locales/en/review.json index 5ce7025b..60136720 100644 --- a/packages/api/src/i18n/locales/en/review.json +++ b/packages/api/src/i18n/locales/en/review.json @@ -24,7 +24,6 @@ "everySingle_year": "Every year", "every_year": "Every {{count}} years", "emptyTitle": "Nothing to review", - "doneTitle": "Review finished", "upcoming_one": "Next review on {{date}} — {{count}} item", "upcoming_other": "Next review on {{date}} — {{count}} items", "noUpcoming": "Nothing is scheduled for review.", diff --git a/packages/api/src/i18n/locales/en/settings.json b/packages/api/src/i18n/locales/en/settings.json index a0ca4ccb..8f33fd85 100644 --- a/packages/api/src/i18n/locales/en/settings.json +++ b/packages/api/src/i18n/locales/en/settings.json @@ -48,7 +48,6 @@ "exportDescription": "Export all your data as a JSON file for backup or migration.", "exportButton": "Export", "exporting": "Exporting…", - "exportSuccess": "Export successful", "exportFailed": "Export failed", "deleteAccount": "Delete Account", "deleteAccountDescription": "Permanently delete your account and all data. This cannot be undone.", @@ -95,11 +94,9 @@ "assistantModelIdHint": "Model name on the endpoint, e.g. deepseek-chat", "assistantTest": "Test connection", "assistantTesting": "Testing…", - "assistantTestOk": "Connection successful", "assistantTestFailed": "Connection failed", "assistantSave": "Save", "assistantSaving": "Saving…", - "assistantSaved": "Saved", "assistantConfigured": "Configured", "assistantNotConfigured": "Not configured", "assistantSaveFailed": "Failed to save", @@ -145,6 +142,7 @@ "shortcut_tags": "Edit tags", "shortcut_duplicate": "Duplicate selected items", "shortcut_logCompleted": "Log completed items", + "shortcut_undo": "Undo", "shortcut_expand": "Open selected task", "shortcut_search": "Quick Find", "shortcut_toggleAssistantPanel": "Toggle assistant panel", diff --git a/packages/api/src/i18n/locales/en/tag.json b/packages/api/src/i18n/locales/en/tag.json index 2c8ddc1e..9f774eaa 100644 --- a/packages/api/src/i18n/locales/en/tag.json +++ b/packages/api/src/i18n/locales/en/tag.json @@ -5,7 +5,8 @@ "titlePlaceholder": "Tag name", "deleteConfirm": "Delete tag \"{{name}}\"?", "defaultTitle": "Tag", - "deleted": "Tag deleted", + "deleteDescription": "The tag will be removed from all items. This can't be undone.", + "deleteDescriptionWithChildren": "The tag will be removed from all items and its child tags moved to the top level. This can't be undone.", "created": "Tag created", "selectColor": "Select color {{color}}", "noTasks": "No tasks with this tag", @@ -23,6 +24,5 @@ "moveToTop": "Top level", "expand": "Expand", "collapse": "Collapse", - "deletedChildrenPromoted": "Tag deleted; its child tags moved to the top level", "parentPath": "Parent tags" } diff --git a/packages/api/src/i18n/locales/en/task.json b/packages/api/src/i18n/locales/en/task.json index 2c4c17fa..f9ecc7c1 100644 --- a/packages/api/src/i18n/locales/en/task.json +++ b/packages/api/src/i18n/locales/en/task.json @@ -58,8 +58,6 @@ "move": "Move", "duplicate": "Duplicate", "logCompleted": "Log Completed", - "logCompletedDone": "Moved completed items to the Logbook", - "duplicateProjectDone": "Project duplicated", "placement": "Located in {{place}}", "placementGoToProject": "Go to project", "placementChangeProject": "Change project", @@ -72,7 +70,6 @@ "whenQueryPlaceholder": "Type a date, e.g. next fri", "deadlineQueryPlaceholder": "Type a deadline, e.g. in 2 weeks", "whenQueryNoResults": "Couldn't understand that date", - "convertSuccess": "Converted to project", "convertFailed": "Conversion failed", "quickAddPlaceholder": "Add a task to Inbox…", "quickAddNotSignedIn": "Sign in from the main window to use quick add", @@ -116,7 +113,6 @@ "skipOccurrence": "Skip Occurrence", "skipOccurrenceLast": "This is the last occurrence", "skipOccurrenceNextExists": "The next occurrence already exists", - "skipOccurrenceDone": "Skipped to {{date}}", "repeatPreviewHint": "Next occurrence of a repeating task", "quickAddTitlePlaceholder": "New task", "quickAddPlacement": "Where", diff --git a/packages/api/src/i18n/locales/zh/auth.json b/packages/api/src/i18n/locales/zh/auth.json index 3ac010fa..b2e1c15b 100644 --- a/packages/api/src/i18n/locales/zh/auth.json +++ b/packages/api/src/i18n/locales/zh/auth.json @@ -27,8 +27,6 @@ "changePassword": "修改密码", "currentPassword": "当前密码", "newPassword": "新密码", - "profileSaved": "资料已保存", - "passwordSaved": "密码已修改", "passwordChangeFailed": "修改密码失败", "sessionExpired": "登录会话已过期,请重新登录", "sessionSaveFailed": "无法保存登录状态,请检查本地存储后重试。", diff --git a/packages/api/src/i18n/locales/zh/calendar.json b/packages/api/src/i18n/locales/zh/calendar.json index 5f8ea25a..887f2e24 100644 --- a/packages/api/src/i18n/locales/zh/calendar.json +++ b/packages/api/src/i18n/locales/zh/calendar.json @@ -15,7 +15,6 @@ "subscriptionNamePlaceholder": "缺省使用日历自带的名称", "addSubscription": "添加", "addingSubscription": "正在获取日历…", - "subscriptionAdded": "已添加日历「{{name}}」", "noSubscriptions": "还没有订阅任何日历", "subscriptionEnabled": "显示该日历的日程", "subscriptionColor": "颜色", diff --git a/packages/api/src/i18n/locales/zh/common.json b/packages/api/src/i18n/locales/zh/common.json index ca584f22..e01a686c 100644 --- a/packages/api/src/i18n/locales/zh/common.json +++ b/packages/api/src/i18n/locales/zh/common.json @@ -41,7 +41,6 @@ "emptyTrashConfirmTitle": "倾倒废纸篓?", "emptyTrashConfirmDescription": "将永久删除废纸篓中的所有任务和项目,此操作不可恢复。", "emptyTrashConfirmAction": "倾倒", - "emptyTrashSuccess": "废纸篓已倾倒", "emptyTrashFailed": "倾倒废纸篓失败", "add": "新增", "settings": "设置", @@ -74,6 +73,7 @@ "undoActionEdit": "编辑", "undoActionReorder": "排序", "undoActionChange": "修改", + "undoActionLogCompleted": "移入 Logbook", "windowMinimize": "最小化", "windowMaximize": "最大化", "windowRestore": "向下还原", diff --git a/packages/api/src/i18n/locales/zh/project.json b/packages/api/src/i18n/locales/zh/project.json index 6c0bfe94..c7c7605d 100644 --- a/packages/api/src/i18n/locales/zh/project.json +++ b/packages/api/src/i18n/locales/zh/project.json @@ -24,9 +24,7 @@ "moveHeading": "移动", "moveHeadingSearchPlaceholder": "搜索项目…", "moveHeadingNoResults": "没有匹配的项目", - "moveHeadingSuccess": "已移到「{{project}}」", "moveHeadingFailed": "移动分组失败", - "convertSuccess": "已转换为项目", "convertFailed": "转换为项目失败", "notePlaceholder": "备注…", "settled": "已了结", @@ -34,9 +32,7 @@ "unarchive": "取消归档", "archiveHeading": "归档项目分组标题", "unarchiveHeading": "取消归档项目分组标题", - "archiveSuccess": "已归档", "archiveFailed": "归档失败", - "unarchiveSuccess": "已取消归档", "unarchiveFailed": "取消归档失败", "archivedHeadingCount": "{{count}} 个已归档项目分组标题", "laterProjects": "稍后项目", diff --git a/packages/api/src/i18n/locales/zh/review.json b/packages/api/src/i18n/locales/zh/review.json index 73e6ce26..e7e39b4b 100644 --- a/packages/api/src/i18n/locales/zh/review.json +++ b/packages/api/src/i18n/locales/zh/review.json @@ -24,7 +24,6 @@ "everySingle_year": "每年", "every_year": "每 {{count}} 年", "emptyTitle": "没有待回顾的项目或区域", - "doneTitle": "本轮回顾已结束", "upcoming_one": "下次回顾:{{date}},共 {{count}} 个", "upcoming_other": "下次回顾:{{date}},共 {{count}} 个", "noUpcoming": "暂无排定的回顾。", diff --git a/packages/api/src/i18n/locales/zh/settings.json b/packages/api/src/i18n/locales/zh/settings.json index 1b9d0428..a34540d5 100644 --- a/packages/api/src/i18n/locales/zh/settings.json +++ b/packages/api/src/i18n/locales/zh/settings.json @@ -48,7 +48,6 @@ "exportDescription": "将你的所有数据导出为 JSON 文件,用于备份或迁移。", "exportButton": "导出", "exporting": "导出中…", - "exportSuccess": "导出成功", "exportFailed": "导出失败", "deleteAccount": "删除账号", "deleteAccountDescription": "永久删除你的账号及所有数据,此操作不可恢复。", @@ -95,11 +94,9 @@ "assistantModelIdHint": "端点上的模型名称,例如 deepseek-chat", "assistantTest": "测试连接", "assistantTesting": "测试中…", - "assistantTestOk": "连接成功", "assistantTestFailed": "连接失败", "assistantSave": "保存", "assistantSaving": "保存中…", - "assistantSaved": "已保存", "assistantConfigured": "已配置", "assistantNotConfigured": "未配置", "assistantSaveFailed": "保存失败", @@ -145,6 +142,7 @@ "shortcut_tags": "编辑标签", "shortcut_duplicate": "复制选中项", "shortcut_logCompleted": "把完成的条目移入 Logbook", + "shortcut_undo": "撤销", "shortcut_expand": "展开选中任务", "shortcut_search": "快速查找", "shortcut_toggleAssistantPanel": "开关助手面板", diff --git a/packages/api/src/i18n/locales/zh/tag.json b/packages/api/src/i18n/locales/zh/tag.json index abaaaba4..a4e90490 100644 --- a/packages/api/src/i18n/locales/zh/tag.json +++ b/packages/api/src/i18n/locales/zh/tag.json @@ -5,7 +5,8 @@ "titlePlaceholder": "标签名称", "deleteConfirm": "确认删除标签「{{name}}」?", "defaultTitle": "标签", - "deleted": "标签已删除", + "deleteDescription": "标签会从所有条目上移除,此操作无法撤销。", + "deleteDescriptionWithChildren": "标签会从所有条目上移除,其子标签移到顶层。此操作无法撤销。", "created": "标签已创建", "selectColor": "选择颜色 {{color}}", "noTasks": "该标签下没有任务", @@ -23,6 +24,5 @@ "moveToTop": "顶层", "expand": "展开", "collapse": "收起", - "deletedChildrenPromoted": "标签已删除,其子标签移到顶层", "parentPath": "上级标签" } diff --git a/packages/api/src/i18n/locales/zh/task.json b/packages/api/src/i18n/locales/zh/task.json index b95e0abc..5c3603c0 100644 --- a/packages/api/src/i18n/locales/zh/task.json +++ b/packages/api/src/i18n/locales/zh/task.json @@ -57,8 +57,6 @@ "move": "移动", "duplicate": "复制", "logCompleted": "移入 Logbook", - "logCompletedDone": "已把完成的条目移入 Logbook", - "duplicateProjectDone": "已复制项目", "placement": "所属:{{place}}", "placementGoToProject": "前往项目", "placementChangeProject": "更改项目", @@ -71,7 +69,6 @@ "whenQueryPlaceholder": "输入日期,如「下周五」", "deadlineQueryPlaceholder": "输入截止日期,如「两周后」", "whenQueryNoResults": "无法识别的日期", - "convertSuccess": "已转换为项目", "convertFailed": "转换失败", "quickAddPlaceholder": "添加任务到收件箱…", "quickAddNotSignedIn": "请先在主窗口登录后再使用快速添加", @@ -115,7 +112,6 @@ "skipOccurrence": "跳过本次", "skipOccurrenceLast": "已是最后一次,无法跳过", "skipOccurrenceNextExists": "下一次已存在,无法跳过", - "skipOccurrenceDone": "已跳到 {{date}}", "repeatPreviewHint": "重复任务的下一次", "quickAddTitlePlaceholder": "新任务", "quickAddPlacement": "放在哪", diff --git a/packages/api/src/undo/undo-history.test.ts b/packages/api/src/undo/undo-history.test.ts index 1a9dde9e..66f33e97 100644 --- a/packages/api/src/undo/undo-history.test.ts +++ b/packages/api/src/undo/undo-history.test.ts @@ -140,6 +140,39 @@ describe('UndoHistory', () => { expect(await history.undo()).toBe('create'); }); + it('Engine 之外的写入经 record 自带逆操作,与同一输入里的 Engine 写入合成一步', async () => { + const { history, tasks, rawTasks } = device; + let watermark = 'before'; + const task = await tasks.createTask({ title: 'A' }); + history.boundary(); + await Promise.all([ + history.record('logCompleted', async () => { + watermark = 'after'; + return async () => { + watermark = 'before'; + }; + }), + tasks.updateTask(task.id, { title: 'B' }), + ]); + + expect(await history.peek()).toBe('logCompleted'); + expect(await history.undo()).toBe('logCompleted'); + expect(watermark).toBe('before'); + expect((await rawTasks.getTask(task.id)).title).toBe('A'); + }); + + it('未接入 Engine 时 record 照常写入但不记录', async () => { + const { history } = device; + history.detach(); + let ran = false; + await history.record('logCompleted', async () => { + ran = true; + return async () => undefined; + }); + expect(ran).toBe(true); + expect(await history.peek()).toBeNull(); + }); + it('detach 后撤销为空操作', async () => { const { history, tasks } = device; await tasks.createTask({ title: 'A' }); diff --git a/packages/api/src/undo/undo-history.ts b/packages/api/src/undo/undo-history.ts index 13e87b68..5c042535 100644 --- a/packages/api/src/undo/undo-history.ts +++ b/packages/api/src/undo/undo-history.ts @@ -9,14 +9,16 @@ * installUndoBoundaries)之间发起的全部写入。一个动作内部的多次写入 * (完成并派生重复实例、多选批量改期、拖拽整组)因此合成一步撤销。 * - **逆操作**:update 记下被改字段的旧值;create 的逆是删除该条目 - * (Delete Request);删除 Subtask 的逆是按原字段重建(新 id)。 + * (Delete Request);删除 Subtask 的逆是按原字段重建(新 id)。Engine + * 之外的写入(Log Completed 推进账号偏好里的水位线)经 record 自带逆操作。 * - **不可撤销**:其余物理删除(清空 Trash、删除区域 / 标签 / Heading / * 附件、转换为项目)无法还原,发生时清空撤销历史,免得更早的步骤 * 作用在已不存在的条目上。 * - **撤销**:逆序回写,只回写当前值仍等于当时写入值的字段(之后被别的 * 编辑或其他设备改过的字段保持不动);回写是普通的本地写入,照常同步。 * - * 不做重做(Redo);撤销历史只在内存里,重启 / 登出即清空。 + * 不做重做(Redo);撤销历史只在内存里,重启 / 登出即清空。各端把界面用的 + * Engine 交给 attach:手机端摇一摇(UndoPrompt)、桌面端与 Web ⌘Z 直接撤销。 */ import type { Engine, SyncEntity, WireRow } from '@taskora/engine'; @@ -35,12 +37,14 @@ export type UndoAction = | 'tags' | 'edit' | 'reorder' - | 'change'; + | 'change' + | 'logCompleted'; type InverseOp = | { kind: 'revert'; entity: SyncEntity; id: string; before: WireRow; after: WireRow } | { kind: 'remove'; entity: SyncEntity; id: string } - | { kind: 'recreate'; entity: SyncEntity; fields: WireRow }; + | { kind: 'recreate'; entity: SyncEntity; fields: WireRow } + | { kind: 'custom'; action: UndoAction; undo: () => Promise }; interface UndoStep { ops: InverseOp[]; @@ -95,6 +99,19 @@ export class UndoHistory { if (this.steps.length > this.limit) this.steps.shift(); } + /** + * 记录一次 Engine 之外的写入:run 执行写入并返回其逆操作,与 Engine 写入 + * 同样归入当前步。未接入 Engine(REST 模式)时照常执行,不记录。 + */ + record(action: UndoAction, run: () => Promise<() => Promise>): Promise { + if (!this.engine) return run().then(() => undefined); + const step = this.stepForWrite(); + return this.serialize(async () => { + const undo = await run(); + step.ops.push({ kind: 'custom', action, undo }); + }); + } + /** 最近一步可撤销的动作;没有则为 null。会先等进行中的写入记录完毕。 */ async peek(): Promise { await this.settle(); @@ -220,6 +237,9 @@ async function applyInverse(engine: Engine, op: InverseOp): Promise { case 'recreate': await engine.create(op.entity, op.fields); return; + case 'custom': + await op.undo(); + return; } } @@ -238,6 +258,8 @@ function stableJson(value: unknown): string { /** 一步撤销的动作名:按「最能代表这一步」的优先级取第一个命中的。 */ export function actionOf(ops: readonly InverseOp[]): UndoAction { + const custom = ops.find((op) => op.kind === 'custom'); + if (custom) return custom.action; const reverts = ops.flatMap((op) => (op.kind === 'revert' ? [op] : [])); const changed = (keys: string[]) => reverts.some((op) => diff --git a/packages/desktop/src/engine/desktop-engine.ts b/packages/desktop/src/engine/desktop-engine.ts index 00e70161..4852891f 100644 --- a/packages/desktop/src/engine/desktop-engine.ts +++ b/packages/desktop/src/engine/desktop-engine.ts @@ -30,6 +30,7 @@ import { detachLiveQueries, createEngineInvalidator, requestTaskReveal, + undoHistory, } from '@taskora/api'; import { openEngine, @@ -108,12 +109,15 @@ async function startEngine(queryClient: QueryClient): Promise { deviceId, transport: createHttpSyncTransport(), }); - // 全域注入(V2):各域沿用 TaskBackend 已验证的注入模式 - setTaskBackend(createEngineTaskBackend({ engine })); - setProjectBackend(createEngineProjectBackend({ engine })); - setAreaBackend(createEngineAreaBackend({ engine })); - setTagBackend(createEngineTagBackend({ engine })); - setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine })); + // 全域注入(V2):各域沿用 TaskBackend 已验证的注入模式。界面上的编辑经 + // 撤销历史包装的 Engine 写入(⌘Z,ADR-0024);副窗口、quick-add 与提醒 + // 协调器等其余写入仍用原 Engine,不进本窗口的撤销历史。 + const recorded = undoHistory.attach(engine); + setTaskBackend(createEngineTaskBackend({ engine: recorded })); + setProjectBackend(createEngineProjectBackend({ engine: recorded })); + setAreaBackend(createEngineAreaBackend({ engine: recorded })); + setTagBackend(createEngineTagBackend({ engine: recorded })); + setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine: recorded })); registerDevice(deviceId).catch(() => undefined); // 注册失败不阻塞本地使用 // Engine 激活:SSE 只作「触发 engine pull」的提示通道(ADR-0007), // 停用 EventStreamApplier 的缓存手术——界面由响应式查询驱动, @@ -185,6 +189,7 @@ async function startEngine(queryClient: QueryClient): Promise { /** 全域退回 REST(登出 / 装配失败;副窗口退回 REST 时同用)。 */ export function resetEngineBackends(): void { detachLiveQueries(); + undoHistory.detach(); setTaskBackend(undefined); setProjectBackend(undefined); setAreaBackend(undefined); diff --git a/packages/desktop/src/main.tsx b/packages/desktop/src/main.tsx index 2e016c98..b8ee4216 100644 --- a/packages/desktop/src/main.tsx +++ b/packages/desktop/src/main.tsx @@ -97,8 +97,10 @@ async function mount() { }); // Event Stream singleton: connects after login, disconnects on logout. - const { initEventStream } = await import('@taskora/api'); + const { initEventStream, installUndoBoundaries } = await import('@taskora/api'); initEventStream(queryClient); + // ⌘Z 撤销:以用户输入划分撤销步骤(ADR-0024)。 + installUndoBoundaries(); if (secondary) { // 副窗口:UI 读写经主窗口的 Engine;登出时关闭自己。 diff --git a/packages/desktop/src/windows/secondary-engine.ts b/packages/desktop/src/windows/secondary-engine.ts index e20d396c..cf7cb474 100644 --- a/packages/desktop/src/windows/secondary-engine.ts +++ b/packages/desktop/src/windows/secondary-engine.ts @@ -24,6 +24,7 @@ import { setTagBackend, setTaskBackend, TabEngine, + undoHistory, useAuthStore, type UnavailableReason, } from '@taskora/api'; @@ -59,11 +60,13 @@ function startSession(queryClient: QueryClient): void { const channel = createWindowChannel(getCurrentWindow().label); const tab = new TabEngine(channel, readDeviceId()); - setTaskBackend(createEngineTaskBackend({ engine: tab })); - setProjectBackend(createEngineProjectBackend({ engine: tab })); - setAreaBackend(createEngineAreaBackend({ engine: tab })); - setTagBackend(createEngineTagBackend({ engine: tab })); - setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine: tab })); + // 每个窗口有自己的撤销历史(⌘Z 只撤销本窗口里的编辑)。 + const recorded = undoHistory.attach(tab); + setTaskBackend(createEngineTaskBackend({ engine: recorded })); + setProjectBackend(createEngineProjectBackend({ engine: recorded })); + setAreaBackend(createEngineAreaBackend({ engine: recorded })); + setTagBackend(createEngineTagBackend({ engine: recorded })); + setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine: recorded })); // SSE 只作远端变更提示(主窗口据此 pull),界面由响应式查询驱动。 setEventStreamCacheSurgery(false); attachLiveQueries(tab); diff --git a/packages/frontend/src/engine/web-engine.ts b/packages/frontend/src/engine/web-engine.ts index 4b2904bf..ca9ce70a 100644 --- a/packages/frontend/src/engine/web-engine.ts +++ b/packages/frontend/src/engine/web-engine.ts @@ -39,6 +39,7 @@ import { setTagBackend, setTaskBackend, TabEngine, + undoHistory, useAuthStore, type SyncStatus, type TabChannel, @@ -161,12 +162,14 @@ function startSession(queryClient: QueryClient, runtime: WebEngineRuntime, userI }; session = current; - // 全域注入:hooks 读写本标签页的 TabEngine - setTaskBackend(createEngineTaskBackend({ engine: tab })); - setProjectBackend(createEngineProjectBackend({ engine: tab })); - setAreaBackend(createEngineAreaBackend({ engine: tab })); - setTagBackend(createEngineTagBackend({ engine: tab })); - setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine: tab })); + // 全域注入:hooks 读写本标签页的 TabEngine,经撤销历史包装(⌘Z,ADR-0024; + // 每个标签页各有一份历史) + const recorded = undoHistory.attach(tab); + setTaskBackend(createEngineTaskBackend({ engine: recorded })); + setProjectBackend(createEngineProjectBackend({ engine: recorded })); + setAreaBackend(createEngineAreaBackend({ engine: recorded })); + setTagBackend(createEngineTagBackend({ engine: recorded })); + setProjectHeadingBackend(createEngineProjectHeadingBackend({ engine: recorded })); // SSE 只作「远端有变更」的提示(leader 据此 pull),界面由响应式查询驱动 setEventStreamCacheSurgery(false); // 读写都是本地的:浏览器报告离线时 React Query 默认暂停查询与 mutation @@ -369,6 +372,7 @@ function runQueriesWhileOffline(queryClient: QueryClient): () => void { function resetBackends(): void { detachLiveQueries(); + undoHistory.detach(); setTaskBackend(undefined); setProjectBackend(undefined); setAreaBackend(undefined); diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 087e5a21..951e6baf 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -13,6 +13,7 @@ import { hydrateAuthSnapshot, initBlobUploads, initEventStream, + installUndoBoundaries, readLegacyAuthSnapshot, setAppVersion, setUnauthorizedHandler, @@ -60,6 +61,8 @@ initBlobUploads(); // Local-first Engine(local-first-v3 issue 05):登录后实体读写切到 OPFS // 里的本地副本,多标签页由 leader 独占副本;浏览器不支持时保持 REST。 initWebEngine(queryClient); +// ⌘Z 撤销:以用户输入划分撤销步骤(ADR-0024)。 +installUndoBoundaries(); // Web wiring: localStorage-backed token store + API base URL from env. configureTokenStore(createLocalTokenStore()); diff --git a/packages/ui/src/components/common/UndoPrompt.tsx b/packages/ui/src/components/common/UndoPrompt.tsx index 4ad7680d..61f2b11f 100644 --- a/packages/ui/src/components/common/UndoPrompt.tsx +++ b/packages/ui/src/components/common/UndoPrompt.tsx @@ -27,6 +27,7 @@ const ACTION_KEYS: Record = { edit: 'undoActionEdit', reorder: 'undoActionReorder', change: 'undoActionChange', + logCompleted: 'undoActionLogCompleted', }; /** diff --git a/packages/ui/src/components/keyboard/KeyboardShortcuts.test.tsx b/packages/ui/src/components/keyboard/KeyboardShortcuts.test.tsx index ee08f025..33e7ac86 100644 --- a/packages/ui/src/components/keyboard/KeyboardShortcuts.test.tsx +++ b/packages/ui/src/components/keyboard/KeyboardShortcuts.test.tsx @@ -103,7 +103,7 @@ import { useUiInteractionStore } from '@taskora/api'; import { useKeybindingsStore } from '@taskora/api'; import { useAssistantUiStore } from '@taskora/api'; import { mockDesktop } from '@/test/media'; -import { useSelectionScope, useSidebarUiStore, todayDateKey } from '@taskora/api'; +import { useSelectionScope, useSidebarUiStore, todayDateKey, undoHistory } from '@taskora/api'; import { addCalendarDays } from '@taskora/shared'; import { toast } from 'sonner'; import { resetCopiedItems } from './itemClipboard'; @@ -1259,6 +1259,24 @@ describe('KeyboardShortcuts — 剪贴板(⌘C / ⌘V / ⌥⌘V)', () => { press('v', { metaKey: true, altKey: true, code: 'KeyV' }); expect(toast).toHaveBeenCalledWith('task:pasteMoveUnavailable'); }); + + it('⌘Z 撤销最近一步;焦点在输入框里时让给原生文字撤销', () => { + const undo = vi.spyOn(undoHistory, 'undo').mockResolvedValue(null); + renderAt('/today', tasks); + press('z', { metaKey: true }); + expect(undo).toHaveBeenCalledTimes(1); + + const input = document.createElement('input'); + document.body.appendChild(input); + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'z', metaKey: true, bubbles: true, cancelable: true }), + ); + }); + expect(undo).toHaveBeenCalledTimes(1); + input.remove(); + undo.mockRestore(); + }); }); describe('KeyboardShortcuts — 导航弹窗(⇧⌘O)', () => { diff --git a/packages/ui/src/components/keyboard/KeyboardShortcuts.tsx b/packages/ui/src/components/keyboard/KeyboardShortcuts.tsx index a7a5f0ad..033db5e5 100644 --- a/packages/ui/src/components/keyboard/KeyboardShortcuts.tsx +++ b/packages/ui/src/components/keyboard/KeyboardShortcuts.tsx @@ -14,6 +14,7 @@ import { reorderedRowIds, scopeOfRow, todayDateKey, + undoHistory, useAssistantUiStore, useKeybindingsStore, useSelectionStore, @@ -131,9 +132,7 @@ function hasTextSelection(): boolean { /** 侧边栏中的列表行([data-sidebar-nav] 为其路由),按显示顺序。 */ function sidebarRoutes(): string[] { const rows = document.querySelectorAll('[data-sidebar] [data-sidebar-nav]'); - return Array.from(rows).flatMap( - (el) => (el.dataset.sidebarNav ? [el.dataset.sidebarNav] : []), - ); + return Array.from(rows).flatMap((el) => (el.dataset.sidebarNav ? [el.dataset.sidebarNav] : [])); } /** 打字唤起 Quick Find 的前提(事件级前提 isEditableTarget / hasOpenOverlay 已在前面判过)。 */ @@ -396,7 +395,12 @@ export function KeyboardShortcuts({ platform }: Props) { if (targets.length === 0) return; e.preventDefault(); const copied = setCopiedItems( - targets.map(({ id, kind, item, tagIds }) => ({ id, kind, item: item!, tagIds: tagIds ?? [] })), + targets.map(({ id, kind, item, tagIds }) => ({ + id, + kind, + item: item!, + tagIds: tagIds ?? [], + })), ); void writeClipboardText(copied.text); return; @@ -406,6 +410,12 @@ export function KeyboardShortcuts({ platform }: Props) { void pasteFromClipboard(); return; } + // ⌘Z:直接撤销最近一步(对齐 Things Mac;手机端是摇一摇加确认)。 + if (action.type === 'undo') { + e.preventDefault(); + undoHistory.undo().catch(() => toast.error(t('common:operationFailed'))); + return; + } // ⇧⌘E 显示 / 隐藏稍后项:只在项目页、区域页生效,其他页面不拦截 if (action.type === 'toggleLaterItems') { const projectId = /^\/projects\/([^/]+)$/.exec(pathname)?.[1]; @@ -452,9 +462,7 @@ export function KeyboardShortcuts({ platform }: Props) { const yieldFocusToNewRow = (id: string) => { requestAnimationFrame(() => { requestAnimationFrame(() => { - const newRow = document.querySelector( - `[data-selection-row="${id}"]`, - ); + const newRow = document.querySelector(`[data-selection-row="${id}"]`); if ( newRow && document.activeElement instanceof Node && @@ -718,9 +726,7 @@ export function KeyboardShortcuts({ platform }: Props) { }); return; } - const anchorIndex = anchor - ? taskRows.findIndex((r) => r.id === anchor.id) - : -1; + const anchorIndex = anchor ? taskRows.findIndex((r) => r.id === anchor.id) : -1; // 选中 task 行时,创建后把新任务排到选中项下方(跟随现有 // reorderTasks 部分排序语义);否则等同普通新建。 if (anchor?.kind === 'task' && anchorIndex >= 0) { @@ -1056,9 +1062,7 @@ function TypeToFindSink({ pathname }: { pathname: string }) { // input 始终是同一个节点,切换外观不打断组字。 return ( <> - {composing && ( -
- )} + {composing &&
}
({ droppableData: null as null | { onDrop: () => void }, createFromQuickAddDraft: vi.fn(), createTask: vi.fn(), - toastSuccess: vi.fn(), })); vi.mock('@dnd-kit/core', async (importOriginal) => { @@ -38,7 +37,7 @@ vi.mock('@taskora/api', async (importOriginal) => ({ useUpdateTask: () => ({ mutateAsync: vi.fn() }), })); -vi.mock('sonner', () => ({ toast: { success: harness.toastSuccess, error: vi.fn() } })); +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); // 卡片本身另有测试;这里只要能提交一份草稿。 vi.mock('@/components/task/QuickAddCard', async () => { @@ -79,7 +78,6 @@ beforeEach(() => { harness.dnd = null; harness.droppableData = null; harness.createFromQuickAddDraft.mockReset(); - harness.toastSuccess.mockReset(); }); describe('MagicPlusInbox', () => { @@ -117,7 +115,6 @@ describe('MagicPlusInbox', () => { { title: '买牛奶' }, expect.objectContaining({ createTask: expect.any(Function) }), ); - expect(harness.toastSuccess).toHaveBeenCalled(); expect(screen.queryByRole('button', { name: 'submit-draft' })).toBeNull(); }); }); diff --git a/packages/ui/src/components/layout/MagicPlusInbox.tsx b/packages/ui/src/components/layout/MagicPlusInbox.tsx index 7c2d3d9a..3b90dc55 100644 --- a/packages/ui/src/components/layout/MagicPlusInbox.tsx +++ b/packages/ui/src/components/layout/MagicPlusInbox.tsx @@ -53,9 +53,6 @@ export function MagicPlusInbox() { try { const result = await createFromQuickAddDraft(draft, deps); if (!result) return; - const place = - result.placedIn.kind === 'inbox' ? t('nav:inbox') : result.placedIn.title; - toast.success(t('task:quickAddAddedTo', { place })); if (!keepOpen) setOpen(false); } catch (error) { toast.error(t('task:quickAddFailed')); diff --git a/packages/ui/src/components/project/ProjectContextMenu.tsx b/packages/ui/src/components/project/ProjectContextMenu.tsx index 2eebd82e..d42b1ef3 100644 --- a/packages/ui/src/components/project/ProjectContextMenu.tsx +++ b/packages/ui/src/components/project/ProjectContextMenu.tsx @@ -528,10 +528,9 @@ export function ProjectMoreMenu({ project, current, variant = 'default' }: Proje const onDeleted = () => { if (!inReview) navigate('/today'); }; - // 项目页里复制:打开副本;回顾中留在回顾会话,只提示。 + // 项目页里复制:打开副本;回顾中留在回顾会话。 const onDuplicated = (copyId: string) => { - if (inReview) toast.success(t('duplicateProjectDone')); - else navigate(`/projects/${copyId}`); + if (!inReview) navigate(`/projects/${copyId}`); }; const [menuOpen, setMenuOpen] = React.useState(false); const [activePicker, setActivePicker] = React.useState(null); diff --git a/packages/ui/src/components/project/ProjectHeadingRow.test.tsx b/packages/ui/src/components/project/ProjectHeadingRow.test.tsx index d1060c78..ee61e103 100644 --- a/packages/ui/src/components/project/ProjectHeadingRow.test.tsx +++ b/packages/ui/src/components/project/ProjectHeadingRow.test.tsx @@ -183,7 +183,6 @@ describe('ProjectHeadingRow', () => { expect(mutationMocks.convert).toHaveBeenCalledWith( 'heading-1', expect.objectContaining({ - onSuccess: expect.any(Function), onError: expect.any(Function), }), ); @@ -210,7 +209,6 @@ describe('ProjectHeadingRow', () => { expect(mutationMocks.move).toHaveBeenCalledWith( { id: 'heading-1', targetProjectId: 'project-2' }, expect.objectContaining({ - onSuccess: expect.any(Function), onError: expect.any(Function), }), ); @@ -235,7 +233,6 @@ describe('ProjectHeadingRow', () => { expect(mutationMocks.archive).toHaveBeenCalledWith( 'heading-1', expect.objectContaining({ - onSuccess: expect.any(Function), onError: expect.any(Function), }), ); @@ -276,7 +273,6 @@ describe('ProjectHeadingRow', () => { expect(mutationMocks.unarchive).toHaveBeenCalledWith( 'heading-1', expect.objectContaining({ - onSuccess: expect.any(Function), onError: expect.any(Function), }), ); diff --git a/packages/ui/src/components/project/ProjectHeadingRow.tsx b/packages/ui/src/components/project/ProjectHeadingRow.tsx index f8d261bf..ff904f35 100644 --- a/packages/ui/src/components/project/ProjectHeadingRow.tsx +++ b/packages/ui/src/components/project/ProjectHeadingRow.tsx @@ -138,15 +138,7 @@ export function ProjectHeadingRow({ heading, selected = false, dragHandleProps } setMoveOpen(false); moveHeading.mutate( { id: heading.id, targetProjectId: project.id }, - { - onSuccess: () => - toast.success( - t('project:moveHeadingSuccess', { - project: project.title || t('project:newItemPlaceholder'), - }), - ), - onError: () => toast.error(t('project:moveHeadingFailed')), - }, + { onError: () => toast.error(t('project:moveHeadingFailed')) }, ); }; const movePicker = ; @@ -167,7 +159,6 @@ export function ProjectHeadingRow({ heading, selected = false, dragHandleProps } disabled: convertHeading.isPending, onSelect: () => convertHeading.mutate(heading.id, { - onSuccess: () => toast.success(t('project:convertSuccess')), onError: () => toast.error(t('project:convertFailed')), }), }, @@ -178,7 +169,6 @@ export function ProjectHeadingRow({ heading, selected = false, dragHandleProps } disabled: unarchiveHeading.isPending, onSelect: () => unarchiveHeading.mutate(heading.id, { - onSuccess: () => toast.success(t('project:unarchiveSuccess')), onError: () => toast.error(t('project:unarchiveFailed')), }), } @@ -188,7 +178,6 @@ export function ProjectHeadingRow({ heading, selected = false, dragHandleProps } disabled: archiveHeading.isPending, onSelect: () => archiveHeading.mutate(heading.id, { - onSuccess: () => toast.success(t('project:archiveSuccess')), onError: () => toast.error(t('project:archiveFailed')), }), }, diff --git a/packages/ui/src/components/project/ProjectSettledTasks.test.tsx b/packages/ui/src/components/project/ProjectSettledTasks.test.tsx index a521edcc..21af2f0a 100644 --- a/packages/ui/src/components/project/ProjectSettledTasks.test.tsx +++ b/packages/ui/src/components/project/ProjectSettledTasks.test.tsx @@ -404,7 +404,6 @@ describe('ProjectSettledTasks', () => { expect(unarchiveMutate).toHaveBeenCalledWith( 'h-1', expect.objectContaining({ - onSuccess: expect.any(Function), onError: expect.any(Function), }), ); diff --git a/packages/ui/src/components/project/useSkipProjectOccurrence.ts b/packages/ui/src/components/project/useSkipProjectOccurrence.ts index 3799d010..c0d2c41f 100644 --- a/packages/ui/src/components/project/useSkipProjectOccurrence.ts +++ b/packages/ui/src/components/project/useSkipProjectOccurrence.ts @@ -6,8 +6,6 @@ import type { ProjectResponseDto } from '@taskora/shared'; import { ProjectStatus } from '@taskora/shared'; import { currentLegacyDateTimeZone, - i18n, - parseCalendarDate, RepeatSkipBlockedError, skipOccurrenceDate, usePreferencesStore, @@ -50,15 +48,6 @@ export function useSkipProjectOccurrence(project: ProjectResponseDto, enabled: b const skip = () => { skipProject.mutate(project.id, { - onSuccess: (skipped) => { - if (!skipped.scheduledDate) return; - const date = new Intl.DateTimeFormat(i18n.language, { - month: 'short', - day: 'numeric', - weekday: 'short', - }).format(parseCalendarDate(skipped.scheduledDate)); - toast.success(t('skipOccurrenceDone', { date })); - }, onError: (error) => toast.error( error instanceof RepeatSkipBlockedError && error.reason === 'next-exists' diff --git a/packages/ui/src/components/settings/SettingsAccount.tsx b/packages/ui/src/components/settings/SettingsAccount.tsx index 4210dbe2..462b43c1 100644 --- a/packages/ui/src/components/settings/SettingsAccount.tsx +++ b/packages/ui/src/components/settings/SettingsAccount.tsx @@ -67,7 +67,6 @@ export default function SettingsAccount() { avatarUrl: avatarUrl || null, }, { - onSuccess: () => toast.success(t('auth:profileSaved')), onError: () => toast.error(t('common:saveFailed')), }, ); @@ -83,7 +82,6 @@ export default function SettingsAccount() { { currentPassword, newPassword }, { onSuccess: () => { - toast.success(t('auth:passwordSaved')); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); diff --git a/packages/ui/src/components/settings/SettingsAssistant.tsx b/packages/ui/src/components/settings/SettingsAssistant.tsx index 4b8eb602..a64ecf10 100644 --- a/packages/ui/src/components/settings/SettingsAssistant.tsx +++ b/packages/ui/src/components/settings/SettingsAssistant.tsx @@ -71,10 +71,7 @@ export default function SettingsAssistant() { ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), }, { - onSuccess: () => { - setApiKey(''); - toast.success(t('settings:assistantSaved')); - }, + onSuccess: () => setApiKey(''), onError: () => toast.error(t('settings:assistantSaveFailed')), }, ); @@ -89,11 +86,8 @@ export default function SettingsAssistant() { ...(modelId.trim() ? { modelId: modelId.trim() } : {}), }, { - onSuccess: (result) => { - setTestResult(result); - if (result.ok) toast.success(t('settings:assistantTestOk')); - else toast.error(t('settings:assistantTestFailed'), { description: result.message }); - }, + // 结果(成功或失败原因)显示在测试按钮旁。 + onSuccess: setTestResult, onError: () => toast.error(t('settings:assistantTestFailed')), }, ); @@ -109,8 +103,7 @@ export default function SettingsAssistant() { if (mobileNav) { // 窄屏:服务商推入 ✓ 选项页;输入框为单元格,说明作分组脚注 - const providerLabel = - AGENT_PROVIDER_PRESETS.find((p) => p.id === provider)?.label ?? provider; + const providerLabel = AGENT_PROVIDER_PRESETS.find((p) => p.id === provider)?.label ?? provider; return ( diff --git a/packages/ui/src/components/settings/SettingsCalendars.tsx b/packages/ui/src/components/settings/SettingsCalendars.tsx index 580e358d..99f7ba5b 100644 --- a/packages/ui/src/components/settings/SettingsCalendars.tsx +++ b/packages/ui/src/components/settings/SettingsCalendars.tsx @@ -120,10 +120,9 @@ export default function SettingsCalendars() { create.mutate( { url: url.trim(), ...(name.trim() ? { name: name.trim() } : {}) }, { - onSuccess: (subscription) => { + onSuccess: () => { setUrl(''); setName(''); - toast.success(t('calendar:subscriptionAdded', { name: subscription.name })); }, onError: (error) => setAddError(fetchErrorText(requestErrorCode(error), t)), }, diff --git a/packages/ui/src/components/settings/SettingsData.tsx b/packages/ui/src/components/settings/SettingsData.tsx index 4673d9e5..4781528d 100644 --- a/packages/ui/src/components/settings/SettingsData.tsx +++ b/packages/ui/src/components/settings/SettingsData.tsx @@ -24,7 +24,6 @@ export default function SettingsData() { a.download = `taskora-export-${todayDateKey()}.json`; a.click(); URL.revokeObjectURL(url); - toast.success(t('settings:exportSuccess')); } catch { toast.error(t('settings:exportFailed')); } diff --git a/packages/ui/src/components/task/ExpandedTaskToolbar.tsx b/packages/ui/src/components/task/ExpandedTaskToolbar.tsx index c923ac91..b5c87820 100644 --- a/packages/ui/src/components/task/ExpandedTaskToolbar.tsx +++ b/packages/ui/src/components/task/ExpandedTaskToolbar.tsx @@ -123,7 +123,6 @@ export function ExpandedTaskToolbar({ variant }: Props) { const id = task.id; void convertGuard.guard(id, () => { convertToProject.mutate(id, { - onSuccess: () => toast.success(t('task:convertSuccess')), onError: () => toast.error(t('task:convertFailed')), }); collapse(); diff --git a/packages/ui/src/components/task/MultiSelectToolbar.tsx b/packages/ui/src/components/task/MultiSelectToolbar.tsx index 8b010d30..f361df78 100644 --- a/packages/ui/src/components/task/MultiSelectToolbar.tsx +++ b/packages/ui/src/components/task/MultiSelectToolbar.tsx @@ -179,7 +179,6 @@ export function MultiSelectToolbar() { const id = single.id; void convertGuard.guard(id, () => { convertToProject.mutate(id, { - onSuccess: () => toast.success(t('task:convertSuccess')), onError: () => toast.error(t('task:convertFailed')), }); exit(); diff --git a/packages/ui/src/components/task/TaskContextMenu.tsx b/packages/ui/src/components/task/TaskContextMenu.tsx index 3d592c7e..a9b396b6 100644 --- a/packages/ui/src/components/task/TaskContextMenu.tsx +++ b/packages/ui/src/components/task/TaskContextMenu.tsx @@ -203,7 +203,6 @@ export function TaskContextMenu({ task, current, children, variant = 'default' } closeMenu(); void convertGuard.guard(task.id, () => convertToProjectTask.mutate(task.id, { - onSuccess: () => toast.success(t('convertSuccess')), onError: () => toast.error(t('convertFailed')), }), ); diff --git a/packages/ui/src/components/task/useLogCompleted.ts b/packages/ui/src/components/task/useLogCompleted.ts index 7c3331e0..64090c1e 100644 --- a/packages/ui/src/components/task/useLogCompleted.ts +++ b/packages/ui/src/components/task/useLogCompleted.ts @@ -6,21 +6,15 @@ import { useLoggingActions, useLoggingMode } from '@taskora/api'; /** * Log Completed(CONTEXT:Log Completed):把所有 Unlogged Item 一次移入 - * Logbook,toast 带撤销。立即模式下没有可移入的条目,available 为 false, - * 各入口隐藏。 + * Logbook,可撤销(撤销历史)。立即模式下没有可移入的条目,available 为 + * false,各入口隐藏。 */ export function useLogCompleted(): { available: boolean; run: () => void } { - const { t } = useTranslation(['task', 'common']); + const { t } = useTranslation('common'); const { logCompleted } = useLoggingActions(); const available = useLoggingMode() !== 'IMMEDIATE'; const run = useCallback(() => { - const failed = () => toast.error(t('common:operationFailed')); - logCompleted().then((undo) => { - if (!undo) return; - toast(t('task:logCompletedDone'), { - action: { label: t('common:undo'), onClick: () => void undo().catch(failed) }, - }); - }, failed); + logCompleted().catch(() => toast.error(t('operationFailed'))); }, [logCompleted, t]); return { available, run }; } diff --git a/packages/ui/src/components/task/useSkipOccurrence.ts b/packages/ui/src/components/task/useSkipOccurrence.ts index e012d1ea..afefa59c 100644 --- a/packages/ui/src/components/task/useSkipOccurrence.ts +++ b/packages/ui/src/components/task/useSkipOccurrence.ts @@ -6,8 +6,6 @@ import type { TaskResponseDto } from '@taskora/shared'; import { TaskStatus } from '@taskora/shared'; import { currentLegacyDateTimeZone, - i18n, - parseCalendarDate, RepeatSkipBlockedError, skipOccurrenceDate, usePreferencesStore, @@ -51,15 +49,6 @@ export function useSkipOccurrence(task: TaskResponseDto | null, enabled: boolean const skip = () => { if (!task) return; skipTask.mutate(task.id, { - onSuccess: (skipped) => { - if (!skipped.scheduledDate) return; - const date = new Intl.DateTimeFormat(i18n.language, { - month: 'short', - day: 'numeric', - weekday: 'short', - }).format(parseCalendarDate(skipped.scheduledDate)); - toast.success(t('skipOccurrenceDone', { date })); - }, onError: (error) => toast.error( error instanceof RepeatSkipBlockedError && error.reason === 'next-exists' diff --git a/packages/ui/src/pages/Review.tsx b/packages/ui/src/pages/Review.tsx index d5a94b64..f0ce2538 100644 --- a/packages/ui/src/pages/Review.tsx +++ b/packages/ui/src/pages/Review.tsx @@ -92,10 +92,9 @@ export default function Review() { navigate(reviewPath(item), { replace: true }); return; } - toast.success(t('doneTitle')); exit(); }, - [navigate, exit, t], + [navigate, exit], ); const { mutate: mutateProject } = markProject; diff --git a/packages/ui/src/pages/Tags.test.tsx b/packages/ui/src/pages/Tags.test.tsx index b02d949e..74eb6764 100644 --- a/packages/ui/src/pages/Tags.test.tsx +++ b/packages/ui/src/pages/Tags.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -10,14 +10,13 @@ const harness = vi.hoisted(() => ({ updateTag: vi.fn(), deleteTag: vi.fn(), reorderTags: vi.fn(), - toast: vi.fn(), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), initReactI18next: { type: '3rdParty', init: () => undefined }, })); -vi.mock('sonner', () => ({ toast: Object.assign(harness.toast, { error: vi.fn() }) })); +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); const NOW = '2026-09-01T00:00:00.000Z'; function tag(id: string, title: string, parentId: string | null = null): TagResponseDto { @@ -156,29 +155,26 @@ describe('Tags 管理页', () => { ); }); - it('删除先隐藏,撤销即恢复且不提交;toast 关闭后才删除', async () => { + it('删除先确认:取消不提交,确认后删除', async () => { renderTags(); await menuAction('urgent', 'common:delete'); - expect(document.querySelector('[data-tag-row="urgent"]')).toBeNull(); - - const options = harness.toast.mock.calls[0][1]; - act(() => options.action.onClick()); - act(() => options.onDismiss()); - expect(document.querySelector('[data-tag-row="urgent"]')).not.toBeNull(); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('tag:deleteDescription')).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'common:cancel' })); expect(harness.deleteTag).not.toHaveBeenCalled(); await menuAction('urgent', 'common:delete'); - const second = harness.toast.mock.calls[1][1]; - act(() => second.onAutoClose()); - act(() => second.onDismiss()); + await user.click( + within(await screen.findByRole('dialog')).getByRole('button', { name: 'common:delete' }), + ); expect(harness.deleteTag).toHaveBeenCalledTimes(1); expect(harness.deleteTag).toHaveBeenCalledWith('urgent', expect.anything()); }); - it('删除父 Tag:等待提交期间子 Tag 即显示到顶层', async () => { + it('删除父 Tag:确认框说明子 Tag 会移到顶层', async () => { renderTags(); await menuAction('place', 'common:delete'); - expect(rowIds()).toEqual(['office', 'urgent']); - expect(harness.toast.mock.calls[0][0]).toBe('tag:deletedChildrenPromoted'); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('tag:deleteDescriptionWithChildren')).toBeInTheDocument(); }); }); diff --git a/packages/ui/src/pages/Tags.tsx b/packages/ui/src/pages/Tags.tsx index 90d61a29..24794604 100644 --- a/packages/ui/src/pages/Tags.tsx +++ b/packages/ui/src/pages/Tags.tsx @@ -28,6 +28,14 @@ import { } from '@taskora/api'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { @@ -122,8 +130,8 @@ interface DragState { * Tags 管理页(tags-things3-v2 issue 05):Tag 树的大纲,层级缩进、可折叠。 * 拖拽沿用 `lib/dnd.ts` 的让位约定(浮层跟手、空位跟随、FLIP 滑动、松手 * 后本地占位),上下决定位置、左右决定层级(见 tagsLayout);「移到…」 - * 不拖拽也能改层级;点名称直接改名、点色点换色。删除先隐藏,toast 关闭后才提交, - * 期间可撤销;删除父 Tag 时子 Tag 提升为顶层。 + * 不拖拽也能改层级;点名称直接改名、点色点换色。删除先确认(不可撤销); + * 删除父 Tag 时子 Tag 提升为顶层。 */ export default function Tags() { const { t } = useTranslation(); @@ -135,18 +143,15 @@ export default function Tags() { const reorderTags = useReorderTags(); const [pending, setPending] = React.useState(null); - // 待提交的删除:先从页面隐藏,撤销即恢复(子 Tag 在等待期间即显示到顶层) - const [hidden, setHidden] = React.useState>(new Set()); + // 待确认删除的 Tag + const [deleting, setDeleting] = React.useState(null); const [collapsed, setCollapsed] = useCollapsedTags(); const [drag, setDrag] = React.useState(null); const dragRef = React.useRef(null); // 碰撞检测里记下的目标行与指针所在半边(onDragMove 每次都读) const targetRef = React.useRef<{ tagId: string; edge: RowEdge } | null>(null); - const forest = React.useMemo( - () => tagForest(tags.filter((tag) => !hidden.has(tag.id))), - [tags, hidden], - ); + const forest = React.useMemo(() => tagForest(tags), [tags]); const rows = React.useMemo(() => buildTagsRows(forest, collapsed), [forest, collapsed]); const displayRows = drag?.rows ?? rows; const flip = useFlipList(displayRows); @@ -228,31 +233,11 @@ export default function Tags() { const draggedTag = drag ? forest.byId.get(drag.tagId) : undefined; - /** 隐藏后弹出可撤销的 toast,toast 关闭(自动或手动)时才真正删除。 */ - const removeTag = (tag: TagResponseDto) => { - const hasChildren = forest.tree.childrenOf(tag.id).length > 0; - setHidden((prev) => new Set(prev).add(tag.id)); - let undone = false; - let committed = false; - const finish = () => { - if (undone || committed) return; - committed = true; - deleteTag.mutate(tag.id, { onError: () => toast.error(t('common:deleteFailed')) }); - }; - toast(hasChildren ? t('tag:deletedChildrenPromoted') : t('tag:deleted'), { - action: { - label: t('common:undo'), - onClick: () => { - undone = true; - setHidden((prev) => { - const next = new Set(prev); - next.delete(tag.id); - return next; - }); - }, - }, - onAutoClose: finish, - onDismiss: finish, + const confirmDelete = () => { + if (!deleting) return; + deleteTag.mutate(deleting.id, { + onSuccess: () => setDeleting(null), + onError: () => toast.error(t('common:deleteFailed')), }); }; @@ -315,7 +300,7 @@ export default function Tags() { onRecolor={(color) => updateTag.mutate({ id: row.tag.id, data: { color } }, { onError: onSaveError }) } - onDelete={() => removeTag(row.tag)} + onDelete={() => setDeleting(row.tag)} onAddChild={() => addChild(row.tag.id)} onMove={(parentId) => applyDrop(resolveTagsMove(forest, row.tag.id, parentId))} /> @@ -345,6 +330,34 @@ export default function Tags() { )} + + !open && setDeleting(null)}> + + + + {t('tag:deleteConfirm', { name: deleting?.title || t('tag:defaultTitle') })} + + + {deleting && forest.tree.childrenOf(deleting.id).length > 0 + ? t('tag:deleteDescriptionWithChildren') + : t('tag:deleteDescription')} + + + + + + + +
); } diff --git a/packages/ui/src/pages/Trash.tsx b/packages/ui/src/pages/Trash.tsx index fda46160..cb2c81ce 100644 --- a/packages/ui/src/pages/Trash.tsx +++ b/packages/ui/src/pages/Trash.tsx @@ -134,10 +134,7 @@ export default function Trash() { disabled={emptyTrashMutation.isPending} onClick={() => { emptyTrashMutation.mutate(undefined, { - onSuccess: () => { - setConfirmOpen(false); - toast.success(t('common:emptyTrashSuccess')); - }, + onSuccess: () => setConfirmOpen(false), onError: () => toast.error(t('common:emptyTrashFailed')), }); }}