diff --git a/README.md b/README.md index 3f24eb7..7d2a54f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - 固定三列:**待办 / 进行中 / 已完成** - **已完成列按完成时间倒序**:最近完成的永远在最上面(列内不支持手动调序) - 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态 +- **全局撤销 / 重做**:`Ctrl+Z` 回退最近 50 步操作,`Ctrl+Shift+Z` 重做;批量改属性、批量归档、标签重命名都能回退 - **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期 - **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档 - **回收站**:删除是软删除,30 天内可随时恢复;也可永久删除或清空 @@ -78,12 +79,16 @@ npm run dev ## 快捷键 -| 按键 | 作用 | -| ----- | ------------------------------ | -| `N` | 聚焦「待办」列的快速新增输入框 | -| `/` | 聚焦搜索框 | -| `?` | 打开快捷键帮助 | -| `Esc` | 关闭当前面板 | +| 按键 | 作用 | +| -------------- | ------------------------------------- | +| `N` | 聚焦「待办」列的快速新增输入框 | +| `/` | 聚焦搜索框 | +| `?` | 打开快捷键帮助 | +| `Esc` | 关闭当前面板 | +| `Ctrl+Z` | 撤销上一步操作(macOS 用 `⌘Z`) | +| `Ctrl+Shift+Z` | 重做被撤销的操作(`Ctrl+Y` 同样可用) | + +撤销历史只保留在内存里:刷新页面或换一个标签页即清空。在输入框内按 `Ctrl+Z` 撤销的是**文字输入**,不是任务操作。 卡片获得焦点(`Tab` 或点击)后: @@ -347,5 +352,6 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx - 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。 - 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。 - 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。 -- 批量改属性不提供撤销(标签是增删语义,误改可以再改回来);导入冲突策略固定为「`updatedAt` 新者胜」,不可配置。 +- 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。 +- 导入冲突策略固定为「`updatedAt` 新者胜」,不可配置。 - 标签还没有颜色,重命名 / 合并会同步落盘但不会保留旧名映射。 diff --git a/docs/adr.md b/docs/adr.md index a7036e2..1b815de 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -191,6 +191,8 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的 - _给删除加二次确认_:删除是高频操作,弹窗会拖慢节奏;软删除 + 回收站已经提供了比确认框更好的退路。 - _批量改属性做 `[{id, before}]` 快照式撤销_:需要额外的内存态与 UI 语义,收益低于成本; 标签是增删语义,改错了再改回来即可。批量删除 / 归档仍有 Toast 撤销。 + (**已被 [ADR-010](#adr-010--引入全局撤销栈取代逐个操作做撤销) 推翻**:批量编辑器扩张到 + 四类属性之后,这个取舍不再成立;但实现方式改为整份快照,而非字段级 diff。) - _合并时对整列 `sortOrder` 做 normalize_:会悄悄重排用户已有数据。新增项追加列尾已经足够, 既有冲突留给后续显式整理。 - _给回收站做搜索 / 筛选_:列表按删除时间倒序已经能覆盖「刚删错」这个主场景,先不加。 @@ -282,3 +284,57 @@ ADR-008 否掉了「完成列按时间排序」,理由是维持单一顺序来 `BoardView.visibleColumns`,测试覆盖了「顺序与 `sortOrder` 无关」这一条。 - 已完成列成为只读顺序:拖拽、`↑` `↓`、批量移动(只能移出该列)都不会改变其内部顺序。 +--- + +## ADR-010 · 引入全局撤销栈,取代逐个操作做撤销 + +**背景** + +ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」,理由是「需要额外的内存态与 UI 语义, +收益低于成本」。当时这个判断成立:批量编辑器只能改标签,改错了再改回来即可。 + +但现在批量编辑器已经能一次改动目标列、优先级、截止日期、归档状态四类属性 —— 一次误操作波及的是 +几十条任务的多个字段,逐条手工回滚已不现实。同时 `Ctrl+Z` 是所有人对「撤销」的肌肉记忆, +按下去没反应本身就是一个缺陷。重新评估之后,结论反过来了。 + +**结论** + +1. 撤销 / 重做基于**整份 `tasks` 数组快照**,而不是命令模式。`stores/tasks.ts` 的所有变更都是 + 不可变替换(`tasks.value = [...]`),未被改动的 Task 对象在相邻快照之间共享引用 —— + 50 步历史的实际内存增量只是「被改过的那几个对象」,与任务总数无关。 +2. 快照的采集点是 `commit()` 这唯一的写入出口:维护一个 `lastCommitted` 指针指向 + 「上一次提交后的状态」,`commit()` 时把它压栈并更新指针。**三十多个写方法一行都没改**, + 因此不存在「新增操作忘了写 undo」这类漏点。 +3. `undo()` / `redo()` 刻意**不走 `commit()`**:撤销本身不是一次新的业务变更。若也压栈, + 用户按 `Ctrl+Z` 就会在两步之间来回跳。 +4. `commit()` 同时清空 redo 栈 —— 撤销之后又做了新操作,原来那条重做分支已经与当前状态分叉。 +5. 上限 50 步,超出丢弃最旧的;历史不落盘,刷新即清空。 +6. 快捷键单开一条通道(`matchUndoShortcut`),不复用 `matchShortcut`:后者的语义是「裸键」, + 带修饰键的组合一律交还浏览器(`Ctrl+F` 查找、`Cmd+R` 刷新)。把撤销混进去,这条放行规则就失效了。 +7. 在输入框内不拦截 `Ctrl+Z`:编辑任务详情时应当撤销文字输入,而不是把整个任务的状态回滚。 +8. 历史为空时弹一句短提示(2 秒);成功时不提示 —— 列表已经变了,本身就是反馈。 + +**被否决的方案** + +- _命令模式(每个操作记录逆操作)_:需要为 `createTasks` / `updateTasks` / `moveTask` / + `archiveTasks` / `renameTag` 等三十多个方法各写一份逆操作,且之后每加一个写方法都要记得补。 + 快照方案把「必须做对」的点从 N 处收敛到 `commit()` 一处。 +- _字段级 diff 栈_:字段之间有关联(`status` 与 `completedAt` 必须同步、换列要重算 `sortOrder`), + 字段级还原极易留下不一致的中间态。整份快照天然自洽。 +- _持久化撤销历史到 localStorage_:刷新后仍能撤销「上一次会话」的操作,会让历史与当前数据的 + 边界变模糊,也显著放大配额占用。编辑器的撤销本来就是会话级的。 +- _复用 `matchShortcut` 处理 `Ctrl+Z`_:会迫使该函数区分「允许哪些修饰键」,`Ctrl+F` 之类的 + 放行规则随之含糊。 +- _撤销时弹 Toast 说明撤销了什么_:需要给 `commit()` 加操作名参数,进而改动所有调用点; + 收益只是「多说一句话」,而视觉变化已经足够。 + +**影响** + +- `stores/tasks.ts` 新增 `undo` / `redo` / `canUndo` / `canRedo` / `resetHistory`; + `undoStack` / `redoStack` 只存在于内存。 +- 跨标签页收到同步消息后(`reloadFromRepository`)调用 `resetHistory`:本页的历史已与最新数据分叉, + 保留下来会在下次撤销时抹掉对方的改动。 +- 启动时的过期回收站清理(`purgeExpiredTrash`)也走 `commit()`,因此在 `init()` 末尾显式 + `resetHistory()`,避免「打开页面就能撤销一次数据清理」。 +- ADR-007 中「_批量改属性不做撤销_」一条作废;其余结论(软删除、`activeTasks` 单一剔除点、 + 标签增删语义)不变。 diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index 202a654..55afd6e 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -239,3 +239,44 @@ test('键盘方向键可以移动任务卡片', async ({ page }) => { await page.reload() await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) }) + +test('Ctrl+Z 撤销创建、Ctrl+Shift+Z 重做,刷新后历史清空', async ({ page }) => { + await addTask(page, '撤销测试 A') + await addTask(page, '撤销测试 B') + await expect(page.locator('article')).toHaveCount(2) + + // 焦点从快速新增输入框移开,否则 Ctrl+Z 交给浏览器撤销文字 + await page.locator('body').click({ position: { x: 5, y: 5 } }) + + await page.keyboard.press('Control+z') + await expect(page.locator('article')).toHaveCount(1) + await expect(page.locator('article').filter({ hasText: '撤销测试 A' })).toBeVisible() + + await page.keyboard.press('Control+z') + await expect(page.locator('article')).toHaveCount(0) + + await page.keyboard.press('Control+Shift+z') + await expect(page.locator('article')).toHaveCount(1) + + // 撤销结果真的落盘了 + await page.reload() + await expect(page.locator('article')).toHaveCount(1) + + // 但历史不落盘:刷新之后没有可撤销的步骤,卡片原地不动 + await page.locator('body').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('Control+z') + await expect(page.getByText('没有可撤销的操作')).toBeVisible() + await expect(page.locator('article')).toHaveCount(1) +}) + +test('输入框内 Ctrl+Z 不劫持任务撤销', async ({ page }) => { + await addTask(page, '不该被撤销') + await expect(page.locator('article')).toHaveCount(1) + + // 焦点停在快速新增输入框时,Ctrl+Z 属于文字输入,任务不该被撤销 + const input = page.getByLabel('添加任务').first() + await input.click() + await page.keyboard.press('Control+z') + + await expect(page.locator('article')).toHaveCount(1) +}) diff --git a/src/App.vue b/src/App.vue index 02db36b..48e7e9d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,14 +10,17 @@ import SideDrawer from '@/components/SideDrawer.vue' import TagManagerPanel from '@/components/TagManagerPanel.vue' import ToastHost from '@/components/ToastHost.vue' import TrashPanel from '@/components/TrashPanel.vue' -import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' +import { useKeyboardShortcuts, useUndoShortcuts } from '@/composables/useKeyboardShortcuts' import { useTaskStore } from '@/stores/tasks' import { useThemeStore } from '@/stores/theme' +import { useToastStore } from '@/stores/toast' import { useUiStore } from '@/stores/ui' const SHORTCUTS = [ { keys: 'N', description: '聚焦「待办」列的快速新增输入框' }, { keys: '/', description: '聚焦搜索框' }, + { keys: 'Ctrl+Z', description: '撤销上一步操作' }, + { keys: 'Ctrl+⇧Z', description: '重做被撤销的操作' }, { keys: '?', description: '打开本帮助' }, { keys: 'Esc', description: '关闭当前面板' }, ] as const @@ -33,6 +36,7 @@ const CARD_SHORTCUTS = [ const store = useTaskStore() const ui = useUiStore() const theme = useThemeStore() +const toast = useToastStore() const { notice } = storeToRefs(store) onMounted(() => { @@ -40,6 +44,22 @@ onMounted(() => { theme.init() }) +/** + * 撤销 / 重做。历史为空时给一句提示 —— 否则按键毫无反应, + * 用户会以为是快捷键没生效。成功时不提示:列表已经变了,本身就是反馈。 + */ +function undo(): void { + if (store.undo()) return + toast.show({ message: '没有可撤销的操作', duration: 2000 }) +} + +function redo(): void { + if (store.redo()) return + toast.show({ message: '没有可重做的操作', duration: 2000 }) +} + +useUndoShortcuts({ undo, redo }) + // 帮助面板是应用级的,因此在任何视图下都能用 ? useKeyboardShortcuts({ '?': () => { @@ -80,7 +100,7 @@ useKeyboardShortcuts({
{{ item.keys }}
@@ -96,7 +116,7 @@ useKeyboardShortcuts({
{{ item.keys }}
@@ -105,6 +125,9 @@ useKeyboardShortcuts({

在输入框内输入时不会触发快捷键。

+

+ 撤销与重做在 macOS 上把 Ctrl 换成 ⌘;在输入框内则交还浏览器,撤销的是文字输入。 +

diff --git a/src/composables/useKeyboardShortcuts.spec.ts b/src/composables/useKeyboardShortcuts.spec.ts index a64243a..c8421c8 100644 --- a/src/composables/useKeyboardShortcuts.spec.ts +++ b/src/composables/useKeyboardShortcuts.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { isEditableTarget, matchShortcut, type ShortcutEventLike } from './useKeyboardShortcuts' +import { + isEditableTarget, + matchShortcut, + matchUndoShortcut, + type ShortcutEventLike, + type UndoEventLike, +} from './useKeyboardShortcuts' function makeEvent(overrides: Partial = {}): ShortcutEventLike { return { @@ -14,6 +20,19 @@ function makeEvent(overrides: Partial = {}): ShortcutEventLik } } +function makeUndoEvent(overrides: Partial = {}): UndoEventLike { + return { + key: 'z', + target: document.body, + defaultPrevented: false, + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + ...overrides, + } +} + function makeElement(tag: string, contentEditable = false): HTMLElement { const element = document.createElement(tag) // 用 setAttribute 而非 property:jsdom 不会把 contentEditable 属性反射到 attribute @@ -83,3 +102,45 @@ describe('matchShortcut', () => { expect(matchShortcut(makeEvent({ key: 'z' }), { n: vi.fn() })).toBeNull() }) }) + +describe('matchUndoShortcut', () => { + it('Ctrl+Z 与 Cmd+Z 都是撤销', () => { + expect(matchUndoShortcut(makeUndoEvent())).toBe('undo') + expect(matchUndoShortcut(makeUndoEvent({ ctrlKey: false, metaKey: true }))).toBe('undo') + }) + + it('Shift+Z 是重做', () => { + expect(matchUndoShortcut(makeUndoEvent({ shiftKey: true }))).toBe('redo') + expect( + matchUndoShortcut(makeUndoEvent({ ctrlKey: false, metaKey: true, shiftKey: true })), + ).toBe('redo') + }) + + it('Ctrl+Y 也认作重做,但 Cmd+Y 不认', () => { + expect(matchUndoShortcut(makeUndoEvent({ key: 'y' }))).toBe('redo') + expect(matchUndoShortcut(makeUndoEvent({ key: 'y', ctrlKey: false, metaKey: true }))).toBeNull() + }) + + it('没有修饰键时不是撤销快捷键', () => { + expect(matchUndoShortcut(makeUndoEvent({ ctrlKey: false }))).toBeNull() + }) + + it('其它键不触发', () => { + expect(matchUndoShortcut(makeUndoEvent({ key: 'a' }))).toBeNull() + expect(matchUndoShortcut(makeUndoEvent({ key: 'f' }))).toBeNull() + }) + + it('带 Alt 的组合不劫持', () => { + expect(matchUndoShortcut(makeUndoEvent({ altKey: true }))).toBeNull() + }) + + it('输入框内交还浏览器,撤销的是文字输入', () => { + expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('input') }))).toBeNull() + expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('textarea') }))).toBeNull() + expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('div', true) }))).toBeNull() + }) + + it('已被处理的事件不重复触发', () => { + expect(matchUndoShortcut(makeUndoEvent({ defaultPrevented: true }))).toBeNull() + }) +}) diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts index 660cecf..f3e3e05 100644 --- a/src/composables/useKeyboardShortcuts.ts +++ b/src/composables/useKeyboardShortcuts.ts @@ -58,3 +58,55 @@ export function useKeyboardShortcuts(shortcuts: ShortcutMap): void { onMounted(() => document.addEventListener('keydown', onKeydown)) onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown)) } + +/** 撤销 / 重做命令 */ +export type UndoCommand = 'undo' | 'redo' + +export interface UndoEventLike extends ShortcutEventLike { + shiftKey: boolean +} + +/** + * 解析撤销 / 重做快捷键。 + * + * 单独一条通道,而不是放宽 matchShortcut:那个函数的语义是「裸键」, + * 带修饰键的组合一律交还给浏览器(Ctrl+F 查找、Cmd+R 刷新)。 + * 撤销是唯一需要修饰键的快捷键,混进去会让上述放行规则失效。 + * + * 同样遵守「输入框内不劫持」:编辑任务详情时按 Ctrl+Z 应当撤销文本输入, + * 而不是把整个任务的状态回滚掉。 + * + * 重做:`Shift+Z` 是跨平台通用写法;额外的 `Ctrl+Y` 照顾 Windows 习惯 + * (不认 `Cmd+Y`,macOS 上那不是重做)。 + */ +export function matchUndoShortcut(event: UndoEventLike): UndoCommand | null { + if (event.defaultPrevented) return null + if (event.altKey) return null + if (!event.ctrlKey && !event.metaKey) return null + if (isEditableTarget(event.target)) return null + + const key = event.key.toLowerCase() + if (key === 'y' && event.ctrlKey && !event.shiftKey) return 'redo' + if (key !== 'z') return null + + return event.shiftKey ? 'redo' : 'undo' +} + +export interface UndoHandlers { + undo: () => void + redo: () => void +} + +/** 把撤销 / 重做快捷键挂到 document 上;生命周期与组件一致 */ +export function useUndoShortcuts(handlers: UndoHandlers): void { + function onKeydown(event: KeyboardEvent): void { + const command = matchUndoShortcut(event) + if (!command) return + event.preventDefault() + if (command === 'undo') handlers.undo() + else handlers.redo() + } + + onMounted(() => document.addEventListener('keydown', onKeydown)) + onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown)) +} diff --git a/src/stores/tasks.spec.ts b/src/stores/tasks.spec.ts index 0258081..a4f9611 100644 --- a/src/stores/tasks.spec.ts +++ b/src/stores/tasks.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository' import type { Task } from '@/types/task' -import { useTaskStore } from './tasks' +import { UNDO_LIMIT, useTaskStore } from './tasks' /** * 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。 @@ -872,7 +872,12 @@ describe('批量编辑', () => { const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) save.mockClear() - expect(store.updateTasks(created.map((task) => task.id), { priority: 3 })).toBe(3) + expect( + store.updateTasks( + created.map((task) => task.id), + { priority: 3 }, + ), + ).toBe(3) expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true) expect(save).toHaveBeenCalledTimes(1) }) @@ -1069,7 +1074,9 @@ describe('标签治理', () => { store.createTask({ title: 'C', tags: ['后端'] }) expect(store.renameTag('前端', 'Frontend')).toBe(2) - expect(store.allTags).toEqual(['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))) + expect(store.allTags).toEqual( + ['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')), + ) expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend', '设计']) }) @@ -1109,3 +1116,179 @@ describe('标签治理', () => { expect(store.trashTasks[0]?.tags).toEqual(['前端']) }) }) + +describe('撤销与重做', () => { + it('初始状态下没有可撤销 / 可重做的步骤', () => { + const store = freshStore() + expect(store.canUndo).toBe(false) + expect(store.canRedo).toBe(false) + expect(store.undo()).toBe(false) + expect(store.redo()).toBe(false) + }) + + it('撤销单条创建', () => { + const store = freshStore() + store.createTask({ title: 'A' }) + expect(store.activeTasks).toHaveLength(1) + + expect(store.undo()).toBe(true) + expect(store.activeTasks).toHaveLength(0) + }) + + it('批量创建只需撤销一次', () => { + const store = freshStore() + store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) + expect(store.activeTasks).toHaveLength(3) + + store.undo() + expect(store.activeTasks).toHaveLength(0) + }) + + it('撤销批量更新,恢复所有被改动的字段', () => { + const store = freshStore() + const created = store.createTasks([ + { title: 'A', priority: 1, tags: ['前端'] }, + { title: 'B', priority: 1 }, + ]) + + store.updateTasks( + created.map((task) => task.id), + { priority: 3, status: 'doing', addTags: ['紧急'] }, + ) + expect(store.byColumn.doing).toHaveLength(2) + expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true) + + store.undo() + expect(store.byColumn.todo).toHaveLength(2) + expect(store.byColumn.doing).toHaveLength(0) + expect(store.activeTasks.every((task) => task.priority === 1)).toBe(true) + expect(store.allTags).toEqual(['前端']) + }) + + it('撤销批量归档', () => { + const store = freshStore() + const created = store.createTasks([{ title: 'A' }, { title: 'B' }]) + + expect(store.archiveTasks(created.map((task) => task.id))).toBe(2) + expect(store.activeTasks.every((task) => task.archivedAt !== null)).toBe(true) + + store.undo() + expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true) + }) + + it('撤销状态流转会一并还原 completedAt', () => { + const store = freshStore() + const task = store.createTask({ title: 'A' }) + store.updateTask(task?.id ?? '', { status: 'done' }) + expect(store.activeTasks[0]?.completedAt).not.toBeNull() + + store.undo() + expect(store.activeTasks[0]?.status).toBe('todo') + expect(store.activeTasks[0]?.completedAt).toBeNull() + }) + + it('撤销删除后任务回到原列,且不再出现在回收站', () => { + const store = freshStore() + const task = store.createTask({ title: 'A', status: 'doing' }) + store.removeTask(task?.id ?? '') + expect(store.trashTasks).toHaveLength(1) + + store.undo() + expect(store.trashTasks).toHaveLength(0) + expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A']) + }) + + it('撤销标签重命名', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端'] }) + store.renameTag('前端', 'Frontend') + expect(store.allTags).toEqual(['Frontend']) + + store.undo() + expect(store.allTags).toEqual(['前端']) + }) + + it('撤销后可以重做', () => { + const store = freshStore() + store.createTask({ title: 'A' }) + store.undo() + expect(store.activeTasks).toHaveLength(0) + expect(store.canRedo).toBe(true) + + expect(store.redo()).toBe(true) + expect(store.activeTasks.map((task) => task.title)).toEqual(['A']) + expect(store.canRedo).toBe(false) + }) + + it('撤销之后做新操作会清空重做栈', () => { + const store = freshStore() + store.createTask({ title: 'A' }) + store.undo() + store.createTask({ title: 'B' }) + + expect(store.canRedo).toBe(false) + store.undo() + expect(store.activeTasks).toHaveLength(0) + }) + + it('连续撤销回到最初状态,再按也不会出错', () => { + const store = freshStore() + store.createTask({ title: 'A' }) + store.createTask({ title: 'B' }) + + expect(store.undo()).toBe(true) + expect(store.undo()).toBe(true) + expect(store.undo()).toBe(false) + expect(store.activeTasks).toHaveLength(0) + }) + + it(`历史深度不超过 ${UNDO_LIMIT} 步`, () => { + const store = freshStore() + for (let index = 0; index <= UNDO_LIMIT + 5; index += 1) { + store.createTask({ title: `T${index}` }) + } + + let undone = 0 + while (store.undo()) undone += 1 + + expect(undone).toBe(UNDO_LIMIT) + // 共建了 UNDO_LIMIT + 6 条,撤销上限步数后,最早的 6 条仍在看板上 + expect(store.activeTasks).toHaveLength(6) + }) + + it('撤销结果会落盘', () => { + const repository = createMemoryTaskRepository() + const store = freshStore(repository) + + store.createTask({ title: 'A' }) + expect(repository.load()).toHaveLength(1) + + store.undo() + expect(repository.load()).toHaveLength(0) + }) + + it('启动时清理过期回收站不占用撤销机会', () => { + const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString() + const repository = createMemoryTaskRepository([ + { + id: 'old', + title: '很久以前删除', + description: '', + status: 'todo', + priority: 2, + tags: [], + dueDate: null, + sortOrder: 1024, + createdAt: longAgo, + updatedAt: longAgo, + completedAt: null, + archivedAt: null, + deletedAt: longAgo, + }, + ]) + const store = freshStore(repository) + + expect(store.trashTasks).toHaveLength(0) + expect(store.canUndo).toBe(false) + }) +}) diff --git a/src/stores/tasks.ts b/src/stores/tasks.ts index f396e20..9d8d161 100644 --- a/src/stores/tasks.ts +++ b/src/stores/tasks.ts @@ -4,6 +4,7 @@ * 职责边界: * - Store 是内存态的唯一来源,所有读写都经由 TaskRepository * - 每次变更后 persist + broadcast,保证刷新不丢、多标签页一致 + * - commit() 是唯一的写入出口,撤销历史也只在这一个地方记录 * - 纯计算(排序、分组)委托给 @/utils/sortOrder,便于单独测试 */ @@ -43,6 +44,15 @@ import { /** 跨标签页同步使用的频道名 */ export const SYNC_CHANNEL_NAME = 'kanban.sync' +/** + * 撤销历史的深度上限。 + * + * 快照只持有数组引用:所有变更都是不可变替换(`tasks.value = [...]`), + * 没被改动的 Task 对象在相邻快照之间共享,因此 50 步的实际内存增量 + * 只是「被改过的那几个对象」,与总任务数无关。 + */ +export const UNDO_LIMIT = 50 + export interface CreateTaskInput { title: string description?: string @@ -83,6 +93,28 @@ export const useTaskStore = defineStore('tasks', () => { /** 可用的启动快照,按时间倒序 */ const snapshots = ref([]) + /** + * 撤销 / 重做历史。 + * + * 用「整份 tasks 数组快照」而不是命令模式:所有变更都是不可变替换, + * 未改动的 Task 在相邻快照之间共享引用,代价近乎为零;命令模式则要为 + * 三十多个写方法各写一份逆操作,漏一个就是静默的数据不一致。 + * + * 历史不落盘:刷新页面即清空,与常见编辑器的行为一致。 + */ + const undoStack = ref([]) + const redoStack = ref([]) + + /** 是否还有可撤销 / 可重做的步骤,供界面提示使用 */ + const canUndo = computed(() => undoStack.value.length > 0) + const canRedo = computed(() => redoStack.value.length > 0) + + /** + * 上一次 commit 之后的快照 —— 也就是「下一次变更之前的状态」。 + * commit() 时把它压入撤销栈,因此所有写方法都不必自己记录 before。 + */ + let lastCommitted: Task[] = [] + let repository: TaskRepository | null = null let channel: BroadcastChannel | null = null @@ -145,7 +177,27 @@ export const useTaskStore = defineStore('tasks', () => { channel?.postMessage({ type: 'tasks-changed' } satisfies SyncMessage) } + /** + * 把「本次变更之前的状态」记入撤销栈,并作废 redo。 + * + * redo 必须清空:撤销两步之后又做了新操作,原来那条重做分支已经 + * 和当前状态分叉,继续保留会让 Ctrl+Shift+Z 跳到一个不相干的状态。 + */ + function recordHistory(): void { + undoStack.value = [...undoStack.value, lastCommitted].slice(-UNDO_LIMIT) + redoStack.value = [] + lastCommitted = tasks.value + } + + /** 丢弃全部历史(跨标签页同步后、启动清理后调用) */ + function resetHistory(): void { + undoStack.value = [] + redoStack.value = [] + lastCommitted = tasks.value + } + function commit(): void { + recordHistory() persist() broadcastChange() } @@ -154,6 +206,9 @@ export const useTaskStore = defineStore('tasks', () => { if (!repository) return try { tasks.value = sortByOrder(repository.load()) + // 另一标签页改过数据:本页的撤销历史已经与当前状态分叉。 + // 继续保留会在下次撤销时把对方的改动一并抹掉,因此直接作废。 + resetHistory() } catch { // 另一标签页可能正在写入,读取失败时保留当前内存态即可 } @@ -259,6 +314,9 @@ export const useTaskStore = defineStore('tasks', () => { // 先清理过期回收站再写启动快照,避免快照里又留下一份本该消失的数据 purgeExpiredTrash() + // 启动时的过期清理不是用户操作,不该占用一次撤销机会。 + // 同时这里把 lastCommitted 对齐到加载后的状态,作为本会话的历史基线。 + resetHistory() writeStartupSnapshot() if (options.crossTab !== false) setupChannel() @@ -506,9 +564,7 @@ export const useTaskStore = defineStore('tasks', () => { /** 永久删除多个任务;返回实际删除数量 */ function purgeTasks(ids: readonly string[]): number { const idSet = new Set(ids) - const remaining = tasks.value.filter( - (task) => !(idSet.has(task.id) && task.deletedAt !== null), - ) + const remaining = tasks.value.filter((task) => !(idSet.has(task.id) && task.deletedAt !== null)) const removed = tasks.value.length - remaining.length if (removed === 0) return 0 @@ -582,6 +638,39 @@ export const useTaskStore = defineStore('tasks', () => { commit() } + /** + * 撤销上一次变更。返回是否真的撤销了(历史为空时为 false)。 + * + * 刻意**不走 commit()**:撤销本身不是一次新的业务变更, + * 若也压栈,用户按 Ctrl+Z 就会在两步之间来回跳。 + */ + function undo(): boolean { + const previous = undoStack.value[undoStack.value.length - 1] + if (previous === undefined) return false + + redoStack.value = [...redoStack.value, tasks.value] + undoStack.value = undoStack.value.slice(0, -1) + tasks.value = previous + lastCommitted = previous + persist() + broadcastChange() + return true + } + + /** 重做被撤销的变更;撤销之后又做了新操作时,redo 已在 commit 里被清空 */ + function redo(): boolean { + const next = redoStack.value[redoStack.value.length - 1] + if (next === undefined) return false + + undoStack.value = [...undoStack.value, tasks.value] + redoStack.value = redoStack.value.slice(0, -1) + tasks.value = next + lastCommitted = next + persist() + broadcastChange() + return true + } + /** 导出为可读 JSON(T14) */ function exportJson(): string { return serializeState(tasks.value, true) @@ -703,6 +792,7 @@ export const useTaskStore = defineStore('tasks', () => { channel?.close() channel = null repository = null + resetHistory() } return { @@ -716,6 +806,8 @@ export const useTaskStore = defineStore('tasks', () => { byColumn, counts, allTags, + canUndo, + canRedo, init, createTask, createTasks, @@ -730,6 +822,8 @@ export const useTaskStore = defineStore('tasks', () => { emptyTrash, purgeExpiredTrash, moveTask, + undo, + redo, exportJson, importJson, mergeJson,