feat: 全局撤销/重做(ADR-010)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
- 固定三列:**待办 / 进行中 / 已完成**
|
||||
- **已完成列按完成时间倒序**:最近完成的永远在最上面(列内不支持手动调序)
|
||||
- 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态
|
||||
- **全局撤销 / 重做**:`Ctrl+Z` 回退最近 50 步操作,`Ctrl+Shift+Z` 重做;批量改属性、批量归档、标签重命名都能回退
|
||||
- **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期
|
||||
- **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档
|
||||
- **回收站**:删除是软删除,30 天内可随时恢复;也可永久删除或清空
|
||||
@@ -79,11 +80,15 @@ npm run dev
|
||||
## 快捷键
|
||||
|
||||
| 按键 | 作用 |
|
||||
| ----- | ------------------------------ |
|
||||
| -------------- | ------------------------------------- |
|
||||
| `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
@@ -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` 单一剔除点、
|
||||
标签增删语义)不变。
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user