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
+60
View File
@@ -92,6 +92,66 @@ test('编辑任务时可以直接选已有标签', async ({ page }) => {
await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计') await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计')
}) })
/**
* 批量添加要打通「工具栏 → 抽屉 → store → 看板」四层,单测只到组件边界,
* 所以这里走一遍真实点击:填清单、改目标列、提交、刷新后仍存在。
*/
test('批量添加:一次录入多行并出现在看板上', async ({ page }) => {
await page.getByRole('button', { name: '批量添加' }).click()
const list = page.getByLabel('批量任务清单')
// 打开面板就该能直接粘贴,焦点应落在清单上
await expect(list).toBeFocused()
await list.fill('- 写周报\n- 评审 PR\n- 回归测试')
await page.getByRole('dialog').getByRole('button', { name: '进行中' }).click()
await page.getByRole('dialog').getByRole('button', { name: '高' }).click()
await page.getByRole('button', { name: '添加 3 个任务' }).click()
await expect(page.getByText('已添加 3 个任务')).toBeVisible()
const doing = page.locator('[data-column-id="doing"] article')
await expect(doing).toHaveCount(3)
// 卡片顺序与清单顺序一致(第 1 行在最上面)
expect(await doing.locator('h3').allInnerTexts()).toEqual(['写周报', '评审 PR', '回归测试'])
await page.reload()
await expect(doing).toHaveCount(3)
})
/**
* 批量删除同样跨了「工具栏 → 卡片多选 → 操作条 → store → Toast」,
* 关键要验证三件事:勾选能选/能取消、删除后整批消失、撤销能还原。
*/
test('批量删除:多选后整批删除,撤销可还原', async ({ page }) => {
await addTask(page, '任务一')
await addTask(page, '任务二')
await addTask(page, '任务三')
await page.getByRole('button', { name: '多选', exact: true }).click()
await expect(page.getByText('已选 0 个任务')).toBeVisible()
await page.getByRole('button', { name: '全选 3 个' }).click()
await expect(page.getByText('已选 3 个任务')).toBeVisible()
// 再点一张取消勾选,验证「点选 / 取消」这一对行为
await page.locator('[data-column-id="todo"] article').filter({ hasText: '任务二' }).click()
await expect(page.getByText('已选 2 个任务')).toBeVisible()
await page.getByRole('button', { name: '删除 2 个' }).click()
await expect(page.getByText('已删除 2 个任务')).toBeVisible()
const cards = page.locator('[data-column-id="todo"] article')
await expect(cards).toHaveCount(1)
await expect(cards.first()).toContainText('任务二')
await page.getByRole('button', { name: '撤销' }).click()
await expect(cards).toHaveCount(3)
await page.reload()
await expect(cards).toHaveCount(3)
})
test('删除后可撤销', async ({ page }) => { test('删除后可撤销', async ({ page }) => {
await addTask(page, '待删除任务') await addTask(page, '待删除任务')
await page.locator('article').filter({ hasText: '待删除任务' }).click() await page.locator('article').filter({ hasText: '待删除任务' }).click()
+134
View File
@@ -0,0 +1,134 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import BatchAddDrawer from './BatchAddDrawer.vue'
enableAutoUnmount(afterEach)
function setup(open = true) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
// 抽屉会被 Teleport 到 body,测试中改为就地渲染以便断言
const wrapper = mount(BatchAddDrawer, {
props: { open },
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, wrapper }
}
function buttonByText(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BatchAddDrawer 录入', () => {
it('打开后显示多行清单输入框', () => {
const { wrapper } = setup()
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true)
})
it('按行实时统计将添加的任务数', async () => {
const { wrapper } = setup()
await wrapper.find('textarea').setValue('第一条\n\n- 第二条\n第三条')
expect(buttonByText(wrapper, '个任务')?.text()).toContain('添加 3 个任务')
})
it('清单为空时提交按钮不可用', () => {
const { wrapper } = setup()
const button = buttonByText(wrapper, '添加 0 个任务')
expect(button).toBeDefined()
expect(button?.attributes('disabled')).toBeDefined()
})
it('只有空行时同样不可提交', async () => {
const { wrapper } = setup()
await wrapper.find('textarea').setValue(' \n\n')
expect(buttonByText(wrapper, '添加 0 个任务')?.attributes('disabled')).toBeDefined()
})
})
describe('BatchAddDrawer 提交', () => {
it('按清单顺序创建任务,第一条在最上面', async () => {
const { store, wrapper } = setup()
await wrapper.find('textarea').setValue('第一条\n第二条\n第三条')
await buttonByText(wrapper, '添加 3 个任务')?.trigger('click')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '第三条'])
})
it('目标列、优先级、截止日期与标签整批共用', async () => {
const { store, wrapper } = setup()
await wrapper.find('textarea').setValue('第一条\n第二条')
await buttonByText(wrapper, '进行中')?.trigger('click')
await buttonByText(wrapper, '高')?.trigger('click')
await wrapper.find('input[type="date"]').setValue('2026-10-20')
const tagInput = wrapper.find('input[aria-label="添加标签"]')
await tagInput.setValue('前端')
await tagInput.trigger('keydown.enter')
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
const created = store.byColumn.doing
expect(created.map((task) => task.title)).toEqual(['第一条', '第二条'])
expect(created[0]?.priority).toBe(3)
expect(created[0]?.dueDate).toBe('2026-10-20')
expect(created[0]?.tags).toEqual(['前端'])
expect(created[1]?.tags).toEqual(['前端'])
})
it('提交后请求关闭并提示数量', async () => {
const { wrapper } = setup()
await wrapper.find('textarea').setValue('A\nB')
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
expect(useToastStore().toasts.at(-1)?.message).toBe('已添加 2 个任务')
})
it('取消不创建任何任务', async () => {
const { store, wrapper } = setup()
await wrapper.find('textarea').setValue('A\nB')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('再次打开时清空上一次的残留', async () => {
const { wrapper } = setup()
await wrapper.find('textarea').setValue('上一次的清单')
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
})
it('误提交时可以整批撤销', async () => {
const { store, wrapper } = setup()
await wrapper.find('textarea').setValue('A\nB')
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
const action = useToastStore().toasts.at(-1)?.action
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
})
})
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
/**
* 批量添加任务
*
* 一行一条,整段粘贴即可录入一批 —— 从笔记、Markdown 清单、聊天记录里
* 直接复制过来就能用,行首的「-」「1.」这类列表符号会被自动去掉。
*
* 目标列、优先级、标签、截止日期是这一批**共用**的属性:批量录入时逐个
* 任务调字段不现实,而「先统一建出来、再单独改个别任务」正是看板的用法。
*/
import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
import { parseTaskLines } from '@/utils/batch'
import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue'
import TagInput from './TagInput.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
const PRIORITIES: readonly Priority[] = [1, 2, 3]
const text = ref('')
const status = ref<ColumnId>('todo')
const priority = ref<Priority>(2)
const dueDate = ref<string | null>(null)
const tags = ref<string[]>([])
const titles = computed(() => parseTaskLines(text.value))
const canSubmit = computed(() => titles.value.length > 0)
/** 每次打开都回到干净状态,避免上一次的残留被误提交 */
watch(
() => props.open,
(open) => {
if (!open) return
text.value = ''
status.value = 'todo'
priority.value = 2
dueDate.value = null
tags.value = []
},
)
function submit(): void {
if (!canSubmit.value) return
const created = store.createTasks(
titles.value.map((title) => ({
title,
status: status.value,
priority: priority.value,
dueDate: dueDate.value,
tags: [...tags.value],
})),
)
if (created.length === 0) return
const ids = created.map((task) => task.id)
toast.show({
message: `已添加 ${created.length} 个任务`,
action: { label: '撤销', run: () => store.removeTasks(ids) },
})
emit('close')
}
</script>
<template>
<SideDrawer :open="open" title="批量添加" initial-focus="textarea" @close="emit('close')">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">
任务清单
<span class="ml-1 text-xs font-normal text-slate-400 dark:text-zinc-500">
每行一条,可直接粘贴
</span>
</span>
<textarea
v-model="text"
rows="8"
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm leading-6 text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
placeholder="写周报&#10;评审 PR&#10;回归测试"
aria-label="批量任务清单"
/>
<span class="text-xs text-slate-400 dark:text-zinc-500">
空行会被忽略;行首的「-」「*」「1.」等列表符号会自动去掉
</span>
</label>
<fieldset class="flex flex-col gap-1.5">
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">添加到</legend>
<div class="flex gap-2">
<button
v-for="column in COLUMNS"
:key="column.id"
type="button"
class="rounded-md border px-3 py-1 text-sm transition"
:class="
status === column.id
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
:aria-pressed="status === column.id"
@click="status = column.id"
>
{{ column.title }}
</button>
</div>
</fieldset>
<fieldset class="flex flex-col gap-1.5">
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
<div class="flex gap-2">
<button
v-for="value in PRIORITIES"
:key="value"
type="button"
class="rounded-md border px-3 py-1 text-sm transition"
:class="
priority === value
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
:aria-pressed="priority === value"
@click="priority = value"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
</fieldset>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
<DatePicker v-model="dueDate" />
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
<TagInput v-model="tags" :suggestions="store.allTags" />
</div>
</div>
<template #footer>
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-slate-500 dark:text-zinc-400">
将添加
<strong class="font-semibold text-slate-700 dark:text-zinc-200">
{{ titles.length }}
</strong>
个任务
</span>
<div class="flex gap-2">
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-sky-600 dark:hover:bg-sky-500"
:disabled="!canSubmit"
@click="submit"
>
添加 {{ titles.length }} 个任务
</button>
</div>
</div>
</template>
</SideDrawer>
</template>
+77
View File
@@ -0,0 +1,77 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import BatchSelectionBar from './BatchSelectionBar.vue'
function setup(count = 0, total = 0) {
const wrapper = mount(BatchSelectionBar, { props: { count, total } })
return wrapper
}
function buttonByText(wrapper: ReturnType<typeof setup>, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BatchSelectionBar 展示', () => {
it('显示已选数量', () => {
expect(setup(2, 5).text()).toContain('已选 2 个任务')
})
it('全选按钮带上可见任务总数', () => {
expect(buttonByText(setup(0, 5), '全选')?.text()).toContain('全选 5 个')
})
it('删除按钮带上已选数量', () => {
expect(buttonByText(setup(3, 5), '删除')?.text()).toContain('删除 3 个')
})
it('作为工具条暴露给读屏', () => {
const bar = setup(1, 1)
expect(bar.attributes('role')).toBe('toolbar')
expect(bar.attributes('aria-label')).toBe('批量操作')
})
})
describe('BatchSelectionBar 交互', () => {
it('全选 emit select-all', async () => {
const wrapper = setup(0, 3)
await buttonByText(wrapper, '全选')?.trigger('click')
expect(wrapper.emitted('select-all')).toHaveLength(1)
})
it('清空选择 emit clear', async () => {
const wrapper = setup(2, 3)
await buttonByText(wrapper, '清空选择')?.trigger('click')
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('完成 emit done', async () => {
const wrapper = setup(2, 3)
await buttonByText(wrapper, '完成')?.trigger('click')
expect(wrapper.emitted('done')).toHaveLength(1)
})
it('删除 emit remove', async () => {
const wrapper = setup(2, 3)
await buttonByText(wrapper, '删除')?.trigger('click')
expect(wrapper.emitted('remove')).toHaveLength(1)
})
})
describe('BatchSelectionBar 禁用态', () => {
it('未选中时不能删除', () => {
expect(buttonByText(setup(0, 3), '删除')?.attributes('disabled')).toBeDefined()
})
it('未选中时不能清空', () => {
expect(buttonByText(setup(0, 3), '清空选择')?.attributes('disabled')).toBeDefined()
})
it('已全选时全选按钮不可再点', () => {
expect(buttonByText(setup(3, 3), '全选')?.attributes('disabled')).toBeDefined()
})
it('没有可见任务时全选不可用', () => {
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
})
})
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 多选操作条
*
* 只在多选模式出现,负责「选了多少 / 全选 / 清空 / 删除」。
* 删除不做二次确认:与单条删除一致,靠 Toast 的「撤销」兜底,
* 误删的代价远低于每次批量操作都弹窗确认。
*/
defineProps<{
/** 已选数量 */
count: number
/** 当前可见(筛选后)的任务总数 */
total: number
}>()
const emit = defineEmits<{
'select-all': []
clear: []
remove: []
done: []
}>()
</script>
<template>
<div
class="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
role="toolbar"
aria-label="批量操作"
>
<span>
已选
<strong class="font-semibold">{{ count }}</strong>
个任务
</span>
<button
type="button"
class="rounded border border-sky-200 bg-white/70 px-2 py-1 text-sky-700 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
:disabled="total === 0 || count === total"
@click="emit('select-all')"
>
全选 {{ total }} 个
</button>
<button
type="button"
class="rounded px-2 py-1 text-sky-700 underline-offset-2 transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline dark:text-sky-200"
:disabled="count === 0"
@click="emit('clear')"
>
清空选择
</button>
<div class="ml-auto flex items-center gap-1.5">
<button
type="button"
class="rounded-md border border-sky-200 bg-white/70 px-2.5 py-1 text-sky-700 transition hover:bg-white dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
@click="emit('done')"
>
完成
</button>
<button
type="button"
class="rounded-md border border-rose-300 bg-white px-2.5 py-1 font-medium text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-rose-800 dark:bg-zinc-900 dark:text-rose-400 dark:hover:bg-rose-950"
:disabled="count === 0"
@click="emit('remove')"
>
删除 {{ count }} 个
</button>
</div>
</div>
</template>
+44
View File
@@ -214,3 +214,47 @@ describe('BoardColumn 拖拽事件接线', () => {
expect(options.filter).toBe('button') expect(options.filter).toBe('button')
}) })
}) })
describe('BoardColumn 多选模式', () => {
function mountSelectable(tasks: Task[], selectedIds: Set<string>) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
const wrapper = mount(BoardColumn, {
props: { column: TODO_COLUMN, tasks: store.byColumn.todo, selectable: true, selectedIds },
global: { plugins: [pinia] },
})
return { store, wrapper }
}
it('卡片变为可勾选,selectedIds 决定勾选态', () => {
const { wrapper } = mountSelectable(
[makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })],
new Set(['task-2']),
)
expect(wrapper.find('[data-task-id="task-1"]').attributes('aria-checked')).toBe('false')
expect(wrapper.find('[data-task-id="task-2"]').attributes('aria-checked')).toBe('true')
})
it('点击卡片 emit select 并带上 id', async () => {
const { wrapper } = mountSelectable([makeTask({ id: 'task-1' })], new Set())
await wrapper.find('article').trigger('click')
expect(wrapper.emitted('select')).toEqual([['task-1']])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('多选时禁止拖拽(同列与跨列都不允许)', () => {
const { wrapper } = mountSelectable([makeTask()], new Set())
const same = wrapper.find('[data-column-id="todo"]').element
const other = document.createElement('div')
const onMove = lastDragOptions().onMove
expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false)
expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(false)
})
})
+15 -2
View File
@@ -8,13 +8,21 @@ import { resolveDrop, shouldAllowMove } from '@/utils/drag'
import QuickAddTask from './QuickAddTask.vue' import QuickAddTask from './QuickAddTask.vue'
import TaskCard from './TaskCard.vue' import TaskCard from './TaskCard.vue'
const props = defineProps<{ column: ColumnDef; tasks: Task[]; sortDisabled?: boolean }>() const props = defineProps<{
column: ColumnDef
tasks: Task[]
sortDisabled?: boolean
/** 多选模式:卡片改为可勾选,并且整体禁用拖拽 */
selectable?: boolean
selectedIds?: ReadonlySet<string>
}>()
const emit = defineEmits<{ const emit = defineEmits<{
toggle: [id: string] toggle: [id: string]
open: [id: string] open: [id: string]
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }] move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
keymove: [payload: { id: string; direction: MoveDirection }] keymove: [payload: { id: string; direction: MoveDirection }]
select: [id: string]
}>() }>()
/** 每列一个色点:三列在余光里也能区分开 */ /** 每列一个色点:三列在余光里也能区分开 */
@@ -46,7 +54,9 @@ useDraggable(listRef, {
ghostClass: 'opacity-40', ghostClass: 'opacity-40',
// 在完成勾选按钮上按下时不启动拖拽 // 在完成勾选按钮上按下时不启动拖拽
filter: 'button', filter: 'button',
onMove: (event) => shouldAllowMove(event.from, event.to, props.sortDisabled === true), onMove: (event) =>
props.selectable !== true &&
shouldAllowMove(event.from, event.to, props.sortDisabled === true),
onEnd: (event) => { onEnd: (event) => {
const drop = resolveDrop(event) const drop = resolveDrop(event)
if (!drop) return if (!drop) return
@@ -81,9 +91,12 @@ useDraggable(listRef, {
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :key="task.id"
:task="task" :task="task"
:selectable="selectable === true"
:selected="selectedIds?.has(task.id) ?? false"
@toggle="emit('toggle', $event)" @toggle="emit('toggle', $event)"
@open="emit('open', $event)" @open="emit('open', $event)"
@keymove="emit('keymove', $event)" @keymove="emit('keymove', $event)"
@select="emit('select', $event)"
/> />
<p <p
+43
View File
@@ -188,3 +188,46 @@ describe('BoardToolbar 归档', () => {
expect(filters.includeArchived).toBe(false) expect(filters.includeArchived).toBe(false)
}) })
}) })
describe('BoardToolbar 批量添加', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('点击「批量添加」emit batch-add', async () => {
const { wrapper } = setup()
await buttonByText(wrapper, '批量添加')?.trigger('click')
expect(wrapper.emitted('batch-add')).toHaveLength(1)
})
})
describe('BoardToolbar 多选', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
/** 文案精确匹配:「多选」是「退出多选」的子串 */
function exactButton(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
it('点击「多选」emit toggle-select', async () => {
const { wrapper } = setup()
await exactButton(wrapper, '多选')?.trigger('click')
expect(wrapper.emitted('toggle-select')).toHaveLength(1)
})
it('selecting 为 true 时按钮变成「退出多选」并标记为按下', async () => {
const { wrapper } = setup()
await wrapper.setProps({ selecting: true })
const button = exactButton(wrapper, '退出多选')
expect(button).toBeDefined()
expect(button?.attributes('aria-pressed')).toBe('true')
expect(exactButton(wrapper, '多选')).toBeUndefined()
})
})
+24 -2
View File
@@ -10,9 +10,9 @@ import { isDueToday, isOverdue } from '@/utils/date'
import TagFilter from './TagFilter.vue' import TagFilter from './TagFilter.vue'
defineProps<{ hiddenNote?: string }>() defineProps<{ hiddenNote?: string; selecting?: boolean }>()
const emit = defineEmits<{ archive: [] }>() const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
const store = useTaskStore() const store = useTaskStore()
const filters = useFilterStore() const filters = useFilterStore()
@@ -133,6 +133,28 @@ defineExpose({ focusSearch })
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span> <span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
<div class="ml-auto flex items-center gap-1.5"> <div class="ml-auto flex items-center gap-1.5">
<button
type="button"
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
@click="emit('batch-add')"
>
批量添加
</button>
<button
type="button"
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
:class="
selecting
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="selecting === true"
@click="emit('toggle-select')"
>
{{ selecting ? '退出多选' : '多选' }}
</button>
<button <button
v-if="archivableCount > 0" v-if="archivableCount > 0"
type="button" type="button"
+102
View File
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository' import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import type { ColumnId } from '@/types/task' import type { ColumnId } from '@/types/task'
import QuickAddTask from './QuickAddTask.vue' import QuickAddTask from './QuickAddTask.vue'
@@ -98,3 +99,104 @@ describe('QuickAddTask', () => {
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务') 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)
})
})
+30
View File
@@ -2,11 +2,14 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import type { ColumnId } from '@/types/task' import type { ColumnId } from '@/types/task'
import { parseTaskLines } from '@/utils/batch'
const props = defineProps<{ status: ColumnId }>() const props = defineProps<{ status: ColumnId }>()
const store = useTaskStore() const store = useTaskStore()
const toast = useToastStore()
const title = ref('') const title = ref('')
const inputRef = ref<HTMLInputElement | null>(null) const inputRef = ref<HTMLInputElement | null>(null)
@@ -23,6 +26,32 @@ function submit(): void {
inputRef.value?.focus() inputRef.value?.focus()
} }
/**
* 批量录入的入口之一:粘贴多行文本时按行拆成多条任务。
*
* 单行粘贴不拦截,保持浏览器默认行为 —— 常规输入/替换不该被打断。
* 输入框里已有的草稿并入第一条,既不丢内容,也不会留下会被重复提交的残留。
*/
function onPaste(event: ClipboardEvent): void {
const pasted = parseTaskLines(event.clipboardData?.getData('text') ?? '')
if (pasted.length < 2) return
event.preventDefault()
const draft = title.value.trim()
const titles = draft ? [draft, ...pasted] : pasted
const created = store.createTasks(titles.map((item) => ({ title: item, status: props.status })))
if (created.length === 0) return
title.value = ''
const ids = created.map((task) => task.id)
toast.show({
message: `已批量添加 ${created.length} 个任务`,
action: { label: '撤销', run: () => store.removeTasks(ids) },
})
inputRef.value?.focus()
}
/** 供快捷键(N)调用 */ /** 供快捷键(N)调用 */
function focusInput(): void { function focusInput(): void {
inputRef.value?.focus() inputRef.value?.focus()
@@ -42,6 +71,7 @@ defineExpose({ focusInput })
aria-label="添加任务" aria-label="添加任务"
@compositionstart="composing = true" @compositionstart="composing = true"
@compositionend="composing = false" @compositionend="composing = false"
@paste="onPaste"
/> />
</form> </form>
</template> </template>
+13
View File
@@ -127,6 +127,19 @@ describe('SideDrawer 焦点管理', () => {
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭') expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
}) })
it('initialFocus 指定的元素优先获得焦点', async () => {
const wrapper = mount(SideDrawer, {
props: { open: true, initialFocus: 'textarea' },
slots: { default: '<button type="button">按钮</button><textarea aria-label="清单" />' },
global: { stubs: { teleport: true } },
attachTo: document.body,
})
mounted.push(wrapper)
await flush()
expect(document.activeElement?.getAttribute('aria-label')).toBe('清单')
})
it('关闭后焦点回到打开前的元素', async () => { it('关闭后焦点回到打开前的元素', async () => {
const trigger = document.createElement('button') const trigger = document.createElement('button')
trigger.textContent = '触发按钮' trigger.textContent = '触发按钮'
+14 -2
View File
@@ -1,7 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{ open: boolean; title?: string }>() const props = defineProps<{
open: boolean
title?: string
/**
* 打开时优先聚焦的元素(CSS 选择器)。
* 默认聚焦面板内第一个可聚焦元素,也就是常驻在头部的「关闭」按钮;
* 需要「一打开就能输入」的面板(如批量添加)可用它把焦点放到正文控件上。
*/
initialFocus?: string
}>()
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
@@ -67,7 +76,10 @@ watch(
previouslyFocused = document.activeElement as HTMLElement | null previouslyFocused = document.activeElement as HTMLElement | null
document.addEventListener('keydown', onDocumentKeydown, true) document.addEventListener('keydown', onDocumentKeydown, true)
void nextTick(() => { void nextTick(() => {
const target = focusableElements()[0] ?? panelRef.value const preferred = props.initialFocus
? (panelRef.value?.querySelector<HTMLElement>(props.initialFocus) ?? null)
: null
const target = preferred ?? focusableElements()[0] ?? panelRef.value
target?.focus() target?.focus()
}) })
} else { } else {
+55
View File
@@ -202,3 +202,58 @@ describe('TaskCard 键盘操作', () => {
expect(event.defaultPrevented).toBe(true) expect(event.defaultPrevented).toBe(true)
}) })
}) })
describe('TaskCard 多选态', () => {
function mountSelectable(selected = false) {
return mount(TaskCard, {
props: { task: makeTask({ id: 'task-9' }), selectable: true, selected },
})
}
it('平时不带 checkbox 语义,多选时才加上', () => {
expect(mountCard().find('article').attributes('role')).toBeUndefined()
expect(mountSelectable().find('article').attributes('role')).toBe('checkbox')
})
it('选中态通过 aria-checked 暴露给读屏', () => {
expect(mountSelectable(false).find('article').attributes('aria-checked')).toBe('false')
expect(mountSelectable(true).find('article').attributes('aria-checked')).toBe('true')
})
it('选中态有可辨识的高亮样式', () => {
const classes = mountSelectable(true).find('article').classes().join(' ')
expect(classes).toContain('bg-sky-50')
})
it('多选时点击是「选中」而不是「打开详情」', async () => {
const wrapper = mountSelectable()
await wrapper.find('article').trigger('click')
expect(wrapper.emitted('select')).toEqual([['task-9']])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('多选时 Enter 与空格切换选中', async () => {
const wrapper = mountSelectable()
await wrapper.find('article').trigger('keydown', { key: 'Enter' })
await wrapper.find('article').trigger('keydown', { key: ' ' })
expect(wrapper.emitted('select')).toEqual([['task-9'], ['task-9']])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('多选时方向键不再移动卡片', async () => {
const wrapper = mountSelectable()
await wrapper.find('article').trigger('keydown', { key: 'ArrowRight' })
expect(wrapper.emitted('keymove')).toBeUndefined()
})
it('多选时不显示「完成」勾选按钮,避免与选中勾选混淆', () => {
expect(mountCard().find('article button').exists()).toBe(true)
expect(mountSelectable().find('article button').exists()).toBe(false)
})
})
+65 -9
View File
@@ -6,12 +6,18 @@ import { formatDueDate, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3 const MAX_VISIBLE_TAGS = 3
const props = defineProps<{ task: Task }>() const props = defineProps<{
task: Task
/** 多选模式:卡片改为可勾选,点击是「选中」而不是「打开详情」 */
selectable?: boolean
selected?: boolean
}>()
const emit = defineEmits<{ const emit = defineEmits<{
toggle: [id: string] toggle: [id: string]
open: [id: string] open: [id: string]
keymove: [payload: { id: string; direction: MoveDirection }] keymove: [payload: { id: string; direction: MoveDirection }]
select: [id: string]
}>() }>()
/** 方向键映射;左右换列,上下调顺序 */ /** 方向键映射;左右换列,上下调顺序 */
@@ -22,6 +28,15 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
ArrowDown: 'down', ArrowDown: 'down',
} }
/** 多选态下点击/回车都是「切换选中」;平时是「打开详情」 */
function activate(): void {
if (props.selectable) {
emit('select', props.task.id)
return
}
emit('open', props.task.id)
}
/** /**
* 卡片获得焦点后的键盘操作。 * 卡片获得焦点后的键盘操作。
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。 * 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
@@ -29,10 +44,13 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
function onKeydown(event: KeyboardEvent): void { function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault() event.preventDefault()
emit('open', props.task.id) activate()
return return
} }
// 多选态不做方向键移动:此时拖拽与排序都已停用,避免误操作打乱顺序
if (props.selectable) return
const direction = MOVE_KEYS[event.key] const direction = MOVE_KEYS[event.key]
if (!direction) return if (!direction) return
@@ -45,6 +63,19 @@ const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS)) const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS)) const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/**
* 卡片外观。选中态与「已完成」都要覆盖边框/背景,
* 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。
*/
const cardClass = computed(() => {
if (props.selected) {
return 'border-sky-400 bg-sky-50 dark:border-sky-600 dark:bg-sky-950/40'
}
return isDone.value
? 'border-slate-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900'
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700'
})
/** /**
* 优先级用左侧色条表达,而不是一枚彩色胶囊。 * 优先级用左侧色条表达,而不是一枚彩色胶囊。
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。 * 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
@@ -74,22 +105,47 @@ const priorityTextClass = computed(() => {
<template> <template>
<article <article
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150 dark:border-zinc-800 dark:bg-zinc-900" class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-150"
:class=" :class="cardClass"
isDone
? 'opacity-60'
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-zinc-700'
"
:data-task-id="task.id" :data-task-id="task.id"
:role="selectable ? 'checkbox' : undefined"
:aria-checked="selectable ? selected === true : undefined"
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
tabindex="0" tabindex="0"
@click="emit('open', task.id)" @click="activate"
@keydown="onKeydown" @keydown="onKeydown"
> >
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" /> <span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
<div class="py-2 pr-2.5 pl-3.5"> <div class="py-2 pr-2.5 pl-3.5">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<!--
多选态下用一枚纯装饰的勾选框:真正的可交互元素是整张卡片,
这样读屏与键盘只有「一个」可聚焦控件,不会出现嵌套按钮。
-->
<span
v-if="selectable"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition"
:class="
selected
? 'border-sky-500 bg-sky-500 text-white'
: 'border-slate-300 bg-white dark:border-zinc-600 dark:bg-zinc-800'
"
aria-hidden="true"
>
<svg v-if="selected" viewBox="0 0 12 12" class="size-2.5" fill="none">
<path
d="m2.5 6.2 2.2 2.2 4.8-4.8"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<button <button
v-else
type="button" type="button"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition" class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
:class=" :class="
+126
View File
@@ -114,6 +114,91 @@ describe('快速新增', () => {
}) })
}) })
describe('批量创建', () => {
it('一次创建多条,整批位于列首且保持输入顺序', () => {
const store = freshStore()
store.createTask({ title: '已有任务' })
const created = store.createTasks([
{ title: '第一条' },
{ title: '第二条' },
{ title: '第三条' },
])
expect(titles(created)).toEqual(['第一条', '第二条', '第三条'])
expect(titles(store.byColumn.todo)).toEqual(['第一条', '第二条', '第三条', '已有任务'])
})
it('跳过空标题,其余顺序不变', () => {
const store = freshStore()
const created = store.createTasks([{ title: ' ' }, { title: '有效' }, { title: '' }])
expect(titles(created)).toEqual(['有效'])
expect(store.tasks).toHaveLength(1)
})
it('全部为空时不产生任务,也不落盘', () => {
const repository = createMemoryTaskRepository()
const save = vi.spyOn(repository, 'save')
const store = freshStore(repository)
expect(store.createTasks([{ title: ' ' }, { title: '' }])).toEqual([])
expect(store.tasks).toHaveLength(0)
expect(save).not.toHaveBeenCalled()
})
it('整批只写一次仓库,而不是逐条提交', () => {
const repository = createMemoryTaskRepository()
const save = vi.spyOn(repository, 'save')
const store = freshStore(repository)
save.mockClear()
store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
expect(save).toHaveBeenCalledTimes(1)
})
it('每条任务可以带各自的列、优先级、标签与截止日期', () => {
const store = freshStore()
const created = store.createTasks([
{ title: 'A', status: 'doing', priority: 3, tags: ['前端'], dueDate: '2026-10-20' },
{ title: 'B', status: 'done' },
])
expect(created[0]?.status).toBe('doing')
expect(created[0]?.priority).toBe(3)
expect(created[0]?.tags).toEqual(['前端'])
expect(created[0]?.dueDate).toBe('2026-10-20')
expect(created[1]?.completedAt).not.toBeNull()
})
it('跨列批量时各列内部都保持输入顺序', () => {
const store = freshStore()
store.createTasks([
{ title: '待办一' },
{ title: '进行一', status: 'doing' },
{ title: '待办二' },
{ title: '进行二', status: 'doing' },
])
expect(titles(store.byColumn.todo)).toEqual(['待办一', '待办二'])
expect(titles(store.byColumn.doing)).toEqual(['进行一', '进行二'])
})
it('同列 sortOrder 严格递增,不出现并列', () => {
const store = freshStore()
store.createTasks(Array.from({ length: 30 }, (_, index) => ({ title: `任务 ${index}` })))
const orders = store.byColumn.todo.map((task) => task.sortOrder)
expect(new Set(orders).size).toBe(orders.length)
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
})
it('批量创建的标题首尾空白被去除', () => {
const store = freshStore()
expect(titles(store.createTasks([{ title: ' 写方案 ' }]))).toEqual(['写方案'])
})
})
describe('编辑', () => { describe('编辑', () => {
it('修改标题并刷新 updatedAt', async () => { it('修改标题并刷新 updatedAt', async () => {
const store = freshStore() const store = freshStore()
@@ -193,6 +278,47 @@ describe('删除与撤销', () => {
}) })
}) })
describe('批量删除与撤销', () => {
it('一次删除多条并返回被删任务(保持原顺序)', () => {
const store = freshStore()
const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
const doomed = created.filter((task) => task.title !== 'B').map((task) => task.id)
const removed = store.removeTasks(doomed)
expect(titles(removed)).toEqual(['A', 'C'])
expect(titles(store.byColumn.todo)).toEqual(['B'])
})
it('整批撤销后连原顺序一起还原', () => {
const store = freshStore()
store.createTask({ title: '既有' })
const created = store.createTasks([{ title: 'A' }, { title: 'B' }])
const removed = store.removeTasks(created.map((task) => task.id))
store.restoreTasks(removed)
expect(titles(store.byColumn.todo)).toEqual(['A', 'B', '既有'])
})
it('id 全部不存在时不做任何变更', () => {
const store = freshStore()
store.createTask({ title: 'A' })
expect(store.removeTasks(['不存在'])).toEqual([])
expect(store.tasks).toHaveLength(1)
})
it('已存在的任务不会被重复恢复', () => {
const store = freshStore()
const created = store.createTasks([{ title: 'A' }])
store.restoreTasks(created)
expect(store.tasks).toHaveLength(1)
})
})
describe('拖拽移动', () => { describe('拖拽移动', () => {
it('跨列拖动更新 status 与 completedAt', () => { it('跨列拖动更新 status 与 completedAt', () => {
const store = freshStore() const store = freshStore()
+75 -20
View File
@@ -243,31 +243,59 @@ export const useTaskStore = defineStore('tasks', () => {
if (options.crossTab !== false) setupChannel() if (options.crossTab !== false) setupChannel()
} }
function createTask(input: CreateTaskInput): Task | null { /**
const title = input.title.trim() * 批量创建:一次落盘 + 一次广播,而不是逐条创建时的 N 次写入。
if (!title) return null *
* 位置约定与单条创建一致 —— 新任务插入列首;同时**同一批内保持输入顺序**,
* 即第 1 行在最上面。做法是从后往前逐条插到列首:中点插入本身就保证相邻
* sortOrder 严格递增,因此不必为整批单独设计排序值。
*
* @returns 实际创建的任务,顺序与入参一致;空标题(含纯空白)会被跳过
*/
function createTasks(inputs: readonly CreateTaskInput[]): Task[] {
const drafts = inputs
.map((input) => ({ input, title: input.title.trim() }))
.filter((draft) => draft.title.length > 0)
if (drafts.length === 0) return []
const status = input.status ?? 'todo'
const now = new Date().toISOString() const now = new Date().toISOString()
const task: Task = { /** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
id: createId(), const working = groupByColumn(tasks.value)
title, const created: Task[] = []
description: input.description?.trim() ?? '',
status, for (let index = drafts.length - 1; index >= 0; index -= 1) {
priority: input.priority ?? DEFAULT_PRIORITY, const draft = drafts[index]
tags: normalizeTags(input.tags ?? []), if (!draft) continue
dueDate: input.dueDate ?? null, const { input, title } = draft
// 快速新增插入列首 const status = input.status ?? 'todo'
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
createdAt: now, const task: Task = {
updatedAt: now, id: createId(),
completedAt: status === 'done' ? now : null, title,
archivedAt: null, description: input.description?.trim() ?? '',
status,
priority: input.priority ?? DEFAULT_PRIORITY,
tags: normalizeTags(input.tags ?? []),
dueDate: input.dueDate ?? null,
sortOrder: sortOrderForPosition(working[status], 0),
createdAt: now,
updatedAt: now,
completedAt: status === 'done' ? now : null,
archivedAt: null,
}
working[status] = [task, ...working[status]]
created.unshift(task)
} }
tasks.value = [...tasks.value, task] tasks.value = [...tasks.value, ...created]
commit() commit()
return task return created
}
/** 单条创建:批量创建的特例,语义与插入位置完全一致 */
function createTask(input: CreateTaskInput): Task | null {
return createTasks([input])[0] ?? null
} }
function updateTask(id: string, patch: TaskPatch): Task | null { function updateTask(id: string, patch: TaskPatch): Task | null {
@@ -330,6 +358,30 @@ export const useTaskStore = defineStore('tasks', () => {
commit() commit()
} }
/**
* 批量删除,返回被删除的任务(保持原有顺序)。
* 供「批量添加」的撤销入口使用:一次操作删掉整批,而不是逐条点删除。
*/
function removeTasks(ids: readonly string[]): Task[] {
const idSet = new Set(ids)
const removed = tasks.value.filter((task) => idSet.has(task.id))
if (removed.length === 0) return []
tasks.value = tasks.value.filter((task) => !idSet.has(task.id))
commit()
return removed
}
/** 批量撤销删除:原 sortOrder 一起恢复,位置不变;已存在的不重复插入 */
function restoreTasks(items: readonly Task[]): void {
const existing = new Set(tasks.value.map((task) => task.id))
const restored = items.filter((task) => !existing.has(task.id))
if (restored.length === 0) return
tasks.value = [...tasks.value, ...restored]
commit()
}
/** /**
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。 * 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。 * status 与 sortOrder 在同一次赋值中更新,不存在中间态。
@@ -444,9 +496,12 @@ export const useTaskStore = defineStore('tasks', () => {
allTags, allTags,
init, init,
createTask, createTask,
createTasks,
updateTask, updateTask,
removeTask, removeTask,
restoreTask, restoreTask,
removeTasks,
restoreTasks,
moveTask, moveTask,
exportJson, exportJson,
importJson, importJson,
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { parseTaskLines } from './batch'
describe('parseTaskLines', () => {
it('单行文本返回单元素数组', () => {
expect(parseTaskLines('写周报')).toEqual(['写周报'])
})
it('按行拆分成多条', () => {
expect(parseTaskLines('写周报\n评审 PR\n回归测试')).toEqual(['写周报', '评审 PR', '回归测试'])
})
it('兼容 CRLF 换行', () => {
expect(parseTaskLines('第一条\r\n第二条')).toEqual(['第一条', '第二条'])
})
it('忽略空行与纯空白行', () => {
expect(parseTaskLines('第一条\n\n \n第二条\n')).toEqual(['第一条', '第二条'])
})
it('去掉每行首尾空白', () => {
expect(parseTaskLines(' 写周报 \n\t评审 PR')).toEqual(['写周报', '评审 PR'])
})
it('去掉 Markdown 列表前缀', () => {
expect(parseTaskLines('- 写周报\n* 评审 PR\n+ 回归测试\n• 发版')).toEqual([
'写周报',
'评审 PR',
'回归测试',
'发版',
])
})
it('去掉有序列表前缀', () => {
expect(parseTaskLines('1. 写周报\n2) 评审 PR\n10. 发版')).toEqual(['写周报', '评审 PR', '发版'])
})
it('前缀后没有空格时不当作列表符号', () => {
expect(parseTaskLines('a-b 联调\n-5℃ 环境复核')).toEqual(['a-b 联调', '-5℃ 环境复核'])
})
it('只有列表符号的行被丢弃', () => {
expect(parseTaskLines('- \n- 写周报')).toEqual(['写周报'])
})
it('空字符串返回空数组', () => {
expect(parseTaskLines('')).toEqual([])
expect(parseTaskLines(' \n\n')).toEqual([])
})
it('保留重复标题(同名任务可能是两件事)', () => {
expect(parseTaskLines('评审\n评审')).toEqual(['评审', '评审'])
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* 批量录入的文本解析
*
* 约定「一行一条任务」,便于从笔记、Markdown 清单、聊天记录里整段粘贴。
* 纯函数、无副作用,便于单独测试。
*/
/**
* 常见列表前缀:`- ` / `* ` / `+ ` / `• ` 以及 `1. ` / `1) `。
* 只在前缀后确实跟着空格时才去掉,因此 `a-b`、`-5℃ 复核` 这类标题不受影响。
*/
const LIST_PREFIX = /^\s*(?:[-*+•]|\d+[.)])\s+/
/**
* 把一段文本按行拆成任务标题列表。
*
* - 兼容 `\n` 与 `\r\n`
* - 去掉每行首尾空白与列表前缀
* - 丢弃空行与纯空白行(粘贴时常带上多余换行)
*/
export function parseTaskLines(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.replace(LIST_PREFIX, '').trim())
.filter((line) => line.length > 0)
}
+215
View File
@@ -565,3 +565,218 @@ describe('BoardView 键盘移动', () => {
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A']) 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 个任务')
})
})
+89 -2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import BoardColumn from '@/components/BoardColumn.vue' import BoardColumn from '@/components/BoardColumn.vue'
import BoardToolbar from '@/components/BoardToolbar.vue' import BoardToolbar from '@/components/BoardToolbar.vue'
import BatchAddDrawer from '@/components/BatchAddDrawer.vue'
import BatchSelectionBar from '@/components/BatchSelectionBar.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue' import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useFilterStore } from '@/stores/filters' import { useFilterStore } from '@/stores/filters'
@@ -25,6 +27,13 @@ const { filter, includeArchived } = storeToRefs(filters)
const editingId = ref<string | null>(null) const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null) const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
/** 批量添加面板 */
const batchOpen = ref(false)
/** 多选模式与已选任务;多选时卡片改为勾选、拖拽停用 */
const selecting = ref(false)
const selectedIds = ref<Set<string>>(new Set())
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null) const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>() const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
@@ -95,6 +104,9 @@ function archiveCompleted(): void {
const count = store.archiveTasks(ids) const count = store.archiveTasks(ids)
if (count === 0) return if (count === 0) return
// 归档后这些卡片默认不可见,留在选择里会让「已选」与眼前看到的对不上
if (selecting.value) clearSelection()
toast.show({ toast.show({
message: `已归档 ${count} 个已完成任务`, message: `已归档 ${count} 个已完成任务`,
action: { label: '撤销', run: () => store.unarchiveTasks(ids) }, action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
@@ -104,7 +116,7 @@ function archiveCompleted(): void {
/** 有面板打开时不响应全局快捷键,避免误触 */ /** 有面板打开时不响应全局快捷键,避免误触 */
function whenNoPanel(action: () => void): () => void { function whenNoPanel(action: () => void): () => void {
return () => { return () => {
if (editingId.value !== null || ui.anyPanelOpen) return if (editingId.value !== null || batchOpen.value || selecting.value || ui.anyPanelOpen) return
action() action()
} }
} }
@@ -162,6 +174,65 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus() document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus()
}) })
} }
/* ---------- 多选与批量删除 ---------- */
/** 当前可见(筛选后)的任务 id,全选以它为准:所见即所选 */
const visibleTaskIds = computed(() =>
COLUMNS.flatMap((column) => visibleColumns.value[column.id].map((task) => task.id)),
)
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */
const selectedTasks = computed(() => store.tasks.filter((task) => selectedIds.value.has(task.id)))
const selectedCount = computed(() => selectedTasks.value.length)
function toggleSelectMode(): void {
if (selecting.value) exitSelection()
else selecting.value = true
}
function exitSelection(): void {
selecting.value = false
selectedIds.value = new Set()
}
function toggleSelect(id: string): void {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function selectAllVisible(): void {
selectedIds.value = new Set(visibleTaskIds.value)
}
function clearSelection(): void {
selectedIds.value = new Set()
}
/**
* 删除选中的任务。不做二次确认 —— 与单条删除同一套约定:
* 立即删除 + Toast 撤销,误删可以整批还原(连原顺序一起)。
*/
function removeSelected(): void {
const removed = store.removeTasks(selectedTasks.value.map((task) => task.id))
if (removed.length === 0) return
exitSelection()
toast.show({
message: `已删除 ${removed.length} 个任务`,
action: { label: '撤销', run: () => store.restoreTasks(removed) },
})
}
/**
* 筛选条件一变就清空选择:选择必须始终对应「眼前看得到的卡片」,
* 否则会出现「删掉了看不见的任务」这种难以预料的结果。
*/
watch(filter, () => {
if (selectedIds.value.size > 0) clearSelection()
})
</script> </script>
<template> <template>
@@ -169,9 +240,21 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
<BoardToolbar <BoardToolbar
ref="toolbarRef" ref="toolbarRef"
:hidden-note="hiddenNote" :hidden-note="hiddenNote"
:selecting="selecting"
@archive="archiveCompleted" @archive="archiveCompleted"
@batch-add="batchOpen = true"
@toggle-select="toggleSelectMode"
/> />
<BatchSelectionBar
v-if="selecting"
:count="selectedCount"
:total="visibleTaskIds.length"
@select-all="selectAllVisible"
@clear="clearSelection"
@remove="removeSelected"
@done="exitSelection"
/>
<!-- <!--
移动端:整块区域滚动,各列按内容自然高度排布; 移动端:整块区域滚动,各列按内容自然高度排布;
@@ -185,13 +268,17 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
:column="column" :column="column"
:tasks="visibleColumns[column.id]" :tasks="visibleColumns[column.id]"
:sort-disabled="hiddenCounts[column.id] > 0" :sort-disabled="hiddenCounts[column.id] > 0"
:selectable="selecting"
:selected-ids="selectedIds"
@toggle="toggleTask" @toggle="toggleTask"
@open="openTask" @open="openTask"
@move="handleMove" @move="handleMove"
@keymove="handleKeyMove" @keymove="handleKeyMove"
@select="toggleSelect"
/> />
</div> </div>
<TaskDetailDrawer :task="editingTask" @close="closeDetail" /> <TaskDetailDrawer :task="editingTask" @close="closeDetail" />
<BatchAddDrawer :open="batchOpen" @close="batchOpen = false" />
</div> </div>
</template> </template>