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
+215
View File
@@ -565,3 +565,218 @@ describe('BoardView 键盘移动', () => {
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
})
})
describe('BoardView 批量添加', () => {
function buttonByText(wrapper: ReturnType<typeof mountBoard>, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
it('点击工具栏「批量添加」打开面板', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(false)
await buttonByText(wrapper, '批量添加')?.trigger('click')
await nextTick()
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true)
})
it('批量添加后卡片出现在看板上', async () => {
const { pinia, store } = setup()
const wrapper = mountBoard(pinia)
await buttonByText(wrapper, '批量添加')?.trigger('click')
await nextTick()
await wrapper.find('textarea[aria-label="批量任务清单"]').setValue('写周报\n评审 PR')
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
await nextTick()
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['写周报', '评审 PR'])
expect(wrapper.text()).toContain('写周报')
})
})
describe('BoardView 多选与批量删除', () => {
/** 文案精确匹配:「多选」是「退出多选」的子串 */
function exactButton(wrapper: ReturnType<typeof mountBoard>, text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
async function enterSelection(wrapper: ReturnType<typeof mountBoard>) {
await exactButton(wrapper, '多选')?.trigger('click')
await nextTick()
}
it('点击「多选」后卡片变为可勾选,并出现操作条', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
expect(wrapper.text()).not.toContain('已选')
await enterSelection(wrapper)
expect(wrapper.text()).toContain('已选 0 个任务')
expect(wrapper.find('article').attributes('role')).toBe('checkbox')
})
it('点击卡片即选中,计数同步', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }])
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper.find('[data-task-id]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 1 个任务')
expect(wrapper.find('article').attributes('aria-checked')).toBe('true')
})
it('多选时点击卡片不会打开详情抽屉', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper.find('article').trigger('click')
await nextTick()
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
it('全选选中当前可见的全部任务', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B', status: 'doing' }, { title: 'C' }])
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper
.findAll('button')
.find((node) => node.text().includes('全选'))
?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 3 个任务')
})
it('筛选后全选只覆盖可见任务', async () => {
const { pinia, store } = setup()
store.createTasks([
{ title: '高优先', priority: 3 },
{ title: '普通', priority: 1 },
])
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
useFilterStore().togglePriority(3)
await nextTick()
await wrapper
.findAll('button')
.find((node) => node.text().includes('全选'))
?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 1 个任务')
})
it('删除选中任务后弹出可撤销的提示,撤销连原顺序一起还原', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }])
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper.find('[data-task-id]').trigger('click')
await nextTick()
await wrapper
.findAll('button')
.find((node) => node.text().includes('删除'))
?.trigger('click')
await nextTick()
expect(store.tasks.map((item) => item.title)).toEqual(['B'])
// 删除后自动退出多选
expect(wrapper.text()).not.toContain('已选')
const toast = useToastStore()
expect(toast.toasts.at(-1)?.message).toBe('已删除 1 个任务')
toast.toasts.at(-1)?.action?.run()
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'B'])
})
it('没有选中任务时删除按钮不可用', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
const removeButton = wrapper.findAll('button').find((node) => node.text().includes('删除 0 个'))
expect(removeButton?.attributes('disabled')).toBeDefined()
})
it('筛选条件变化会清空选择,避免删掉看不见的任务', async () => {
const { pinia, store } = setup()
store.createTasks([
{ title: 'A', priority: 3 },
{ title: 'B', priority: 1 },
])
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper.find('[data-task-id]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 1 个任务')
useFilterStore().togglePriority(1)
await nextTick()
expect(wrapper.text()).toContain('已选 0 个任务')
})
it('退出多选后清空选择并恢复卡片的常规语义', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper.find('article').trigger('click')
await nextTick()
await exactButton(wrapper, '退出多选')?.trigger('click')
await nextTick()
expect(wrapper.text()).not.toContain('已选')
expect(wrapper.find('article').attributes('role')).toBeUndefined()
// 恢复后点击卡片重新等于「打开详情」
await wrapper.find('article').trigger('click')
await nextTick()
expect(wrapper.find('[role="dialog"]').exists()).toBe(true)
})
it('归档后清空选择,避免选中已不可见的卡片', async () => {
const { pinia, store } = setup()
const task = store.createTask({ title: '很久前完成', status: 'done' })
store.updateTask(task?.id ?? '', { completedAt: dayjs().subtract(30, 'day').toISOString() })
const wrapper = mountBoard(pinia)
await enterSelection(wrapper)
await wrapper
.findAll('button')
.find((node) => node.text().includes('全选'))
?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 1 个任务')
await wrapper
.findAll('button')
.find((node) => node.text().includes('个已完成'))
?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('已选 0 个任务')
})
})