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,