feat(board): 支持批量添加与批量删除任务

- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期
- 快速新增支持粘贴多行文本,直接拆成多条任务
- 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原
- 批量创建与删除走 store 的批量方法,一次落盘、一次广播
- 补充单元测试与 E2E 用例
This commit is contained in:
chenshoulin
2026-10-19 17:21:42 +08:00
parent 4aba003a9e
commit 2d6e24dc91
21 changed files with 1518 additions and 37 deletions
+102
View File
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import type { ColumnId } from '@/types/task'
import QuickAddTask from './QuickAddTask.vue'
@@ -98,3 +99,104 @@ describe('QuickAddTask', () => {
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
})
})
/**
* 粘贴多行文本 = 批量添加。
* 这里手工构造 paste 事件:jsdom 没有可用的 Clipboard API,
* 而组件只依赖 `clipboardData.getData('text')` 这一小块接口。
*/
function paste(wrapper: ReturnType<typeof setup>['wrapper'], text: string): boolean {
const event = new Event('paste', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'clipboardData', { value: { getData: () => text } })
wrapper.find('input').element.dispatchEvent(event)
return event.defaultPrevented
}
describe('QuickAddTask 批量粘贴', () => {
it('粘贴多行文本创建多条任务,并保持粘贴顺序', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, '写周报\n评审 PR\n回归测试')).toBe(true)
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR', '回归测试'])
})
it('粘贴的多条任务位于已有任务之前', () => {
const { store, wrapper } = setup()
store.createTask({ title: '已有任务' })
paste(wrapper, '第一条\n第二条')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '已有任务'])
})
it('粘贴到指定列(进行中)', () => {
const { store, wrapper } = setup('doing')
paste(wrapper, 'A\nB')
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B'])
expect(store.byColumn.todo).toHaveLength(0)
})
it('单行粘贴不拦截,交回浏览器默认行为', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, '单个任务')).toBe(false)
expect(store.tasks).toHaveLength(0)
})
it('只有空行时不创建任务,也不拦截粘贴', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, ' \n\n')).toBe(false)
expect(store.tasks).toHaveLength(0)
})
it('粘贴时忽略空行与列表符号', () => {
const { store, wrapper } = setup()
paste(wrapper, '- 写周报\n\n- 评审 PR\n')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR'])
})
it('粘贴前已输入的草稿并入第一条,不丢失也不残留', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('手写的一条')
paste(wrapper, '粘贴一\n粘贴二')
expect(store.byColumn.todo.map((task) => task.title)).toEqual([
'手写的一条',
'粘贴一',
'粘贴二',
])
// v-model 的写回要等一轮视图更新
await wrapper.vm.$nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
})
it('批量粘贴后弹出提示并保持聚焦,便于继续录入', () => {
const { wrapper } = setup()
paste(wrapper, 'A\nB')
const toast = useToastStore()
expect(toast.toasts.at(-1)?.message).toBe('已批量添加 2 个任务')
expect(document.activeElement).toBe(wrapper.find('input').element)
})
it('误粘贴时可以整批撤销', () => {
const { store, wrapper } = setup()
paste(wrapper, 'A\nB')
const action = useToastStore().toasts.at(-1)?.action
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
})
})