feat: 全局撤销/重做(ADR-010)

This commit is contained in:
chenshoulin
2026-10-24 21:21:36 +08:00
parent 29100b6d7d
commit 648b1ddca4
8 changed files with 533 additions and 17 deletions
+13 -7
View File
@@ -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` 新者胜」,不可配置。
- 标签还没有颜色,重命名 / 合并会同步落盘但不会保留旧名映射。
+56
View File
@@ -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` 单一剔除点、
标签增删语义)不变。
+41
View File
@@ -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)
})
+26 -3
View File
@@ -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({
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
class="w-20 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{{ item.keys }}
</dt>
@@ -96,7 +116,7 @@ useKeyboardShortcuts({
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in CARD_SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
class="w-20 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{{ item.keys }}
</dt>
@@ -105,6 +125,9 @@ useKeyboardShortcuts({
</dl>
<p class="mt-4 text-xs text-slate-400 dark:text-zinc-500">在输入框内输入时不会触发快捷键。</p>
<p class="mt-1 text-xs text-slate-400 dark:text-zinc-500">
撤销与重做在 macOS 上把 Ctrl 换成 ⌘;在输入框内则交还浏览器,撤销的是文字输入。
</p>
</SideDrawer>
<ToastHost />
+62 -1
View File
@@ -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> = {}): ShortcutEventLike {
return {
@@ -14,6 +20,19 @@ function makeEvent(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLik
}
}
function makeUndoEvent(overrides: Partial<UndoEventLike> = {}): 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()
})
})
+52
View File
@@ -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))
}
+186 -3
View File
@@ -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)
})
})
+97 -3
View File
@@ -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<SnapshotInfo[]>([])
/**
* 撤销 / 重做历史。
*
* 用「整份 tasks 数组快照」而不是命令模式:所有变更都是不可变替换,
* 未改动的 Task 在相邻快照之间共享引用,代价近乎为零;命令模式则要为
* 三十多个写方法各写一份逆操作,漏一个就是静默的数据不一致。
*
* 历史不落盘:刷新页面即清空,与常见编辑器的行为一致。
*/
const undoStack = ref<Task[][]>([])
const redoStack = ref<Task[][]>([])
/** 是否还有可撤销 / 可重做的步骤,供界面提示使用 */
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,